diff --git a/.changeset/heavy-bananas-deny.md b/.changeset/heavy-bananas-deny.md new file mode 100644 index 0000000000..24c34d9bef --- /dev/null +++ b/.changeset/heavy-bananas-deny.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Prevent overcaching .astro HMR changes diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts index 91968affd7..47440c3721 100644 --- a/packages/astro/src/core/create-vite.ts +++ b/packages/astro/src/core/create-vite.ts @@ -96,6 +96,7 @@ export async function createVite( }, plugins: [ configAliasVitePlugin({ settings }), + astroLoadFallbackPlugin({ fs, settings }), astroVitePlugin({ settings, logging }), astroScriptsPlugin({ settings }), // The server plugin is for dev only and having it run during the build causes @@ -110,7 +111,6 @@ export async function createVite( astroPostprocessVitePlugin({ settings }), astroIntegrationsContainerPlugin({ settings, logging }), astroScriptsPageSSRPlugin({ settings }), - astroLoadFallbackPlugin({ fs }), ], publicDir: fileURLToPath(settings.config.publicDir), root: fileURLToPath(settings.config.root), diff --git a/packages/astro/src/vite-plugin-astro/hmr.ts b/packages/astro/src/vite-plugin-astro/hmr.ts index 7d45723e61..17ecf18853 100644 --- a/packages/astro/src/vite-plugin-astro/hmr.ts +++ b/packages/astro/src/vite-plugin-astro/hmr.ts @@ -24,7 +24,7 @@ export async function handleHotUpdate( { config, logging, compile }: HandleHotUpdateOptions ) { let isStyleOnlyChange = false; - if (ctx.file.endsWith('.astro')) { + if (ctx.file.endsWith('.astro') && isCached(config, ctx.file)) { // Get the compiled result from the cache const oldResult = await compile(); // But we also need a fresh, uncached result to compare it to diff --git a/packages/astro/src/vite-plugin-load-fallback/index.ts b/packages/astro/src/vite-plugin-load-fallback/index.ts index f0a983993f..f77f351526 100644 --- a/packages/astro/src/vite-plugin-load-fallback/index.ts +++ b/packages/astro/src/vite-plugin-load-fallback/index.ts @@ -1,34 +1,65 @@ -import nodeFs from 'fs'; +import type { AstroSettings } from '../@types/astro'; import type * as vite from 'vite'; +import nodeFs from 'fs'; +import npath from 'path'; + type NodeFileSystemModule = typeof nodeFs; export interface LoadFallbackPluginParams { fs?: NodeFileSystemModule; + settings: AstroSettings; } -export default function loadFallbackPlugin({ fs }: LoadFallbackPluginParams): vite.Plugin | false { +export default function loadFallbackPlugin({ fs, settings }: LoadFallbackPluginParams): vite.Plugin[] | false { // Only add this plugin if a custom fs implementation is provided. if (!fs || fs === nodeFs) { return false; } - return { - name: 'astro:load-fallback', - enforce: 'post', - async load(id) { + const tryLoadModule = async (id: string) => { + try { + // await is necessary for the catch + return await fs.promises.readFile(cleanUrl(id), 'utf-8'); + } catch (e) { try { - // await is necessary for the catch - return await fs.promises.readFile(cleanUrl(id), 'utf-8'); - } catch (e) { + return await fs.promises.readFile(id, 'utf-8'); + } catch (e2) { try { - return await fs.promises.readFile(id, 'utf-8'); - } catch (e2) { + const fullpath = new URL('.' + id, settings.config.root); + return await fs.promises.readFile(fullpath, 'utf-8'); + } catch (e3) { // Let fall through to the next } } - }, + } }; + + return [{ + name: 'astro:load-fallback', + enforce: 'post', + resolveId(id, parent) { + if(id.startsWith('.') && parent && fs.existsSync(parent)) { + return npath.posix.join(npath.posix.dirname(parent), id); + } + }, + async load(id) { + const source = await tryLoadModule(id); + return source; + } + }, { + name: 'astro:load-fallback-hmr', + enforce: 'pre', + handleHotUpdate(context) { + // Wrap context.read so it checks our filesystem first. + const read = context.read; + context.read = async () => { + const source = await tryLoadModule(context.file); + if(source) return source; + return read.call(context); + }; + } + }]; } const queryRE = /\?.*$/s; diff --git a/packages/astro/test/units/dev/dev.test.js b/packages/astro/test/units/dev/dev.test.js index d8a65d0664..2d45eb354f 100644 --- a/packages/astro/test/units/dev/dev.test.js +++ b/packages/astro/test/units/dev/dev.test.js @@ -2,7 +2,7 @@ import { expect } from 'chai'; import * as cheerio from 'cheerio'; import { runInContainer } from '../../../dist/core/dev/index.js'; -import { createFs, createRequestAndResponse } from '../test-utils.js'; +import { createFs, createRequestAndResponse, triggerFSEvent } from '../test-utils.js'; const root = new URL('../../fixtures/alias/', import.meta.url); @@ -37,4 +37,67 @@ describe('dev container', () => { expect($('h1')).to.have.a.lengthOf(1); }); }); + + it('HMR only short circuits on previously cached modules', async () => { + const fs = createFs( + { + '/src/components/Header.astro': ` +

{Astro.props.title}

+ `, + '/src/pages/index.astro': ` + --- + import Header from '../components/Header.astro'; + const name = 'Testing'; + --- + + {name} + +
+ + + `, + }, + root + ); + + await runInContainer({ fs, root }, async (container) => { + let r = createRequestAndResponse({ + method: 'GET', + url: '/', + }); + container.handle(r.req, r.res); + let html = await r.text(); + let $ = cheerio.load(html); + expect($('body.one')).to.have.a.lengthOf(1); + + fs.writeFileFromRootSync('/src/components/Header.astro', ` +

{Astro.props.title}

+ `); + triggerFSEvent(container, fs, '/src/components/Header.astro', 'change'); + + fs.writeFileFromRootSync('/src/pages/index.astro', ` + --- + import Header from '../components/Header.astro'; + const name = 'Testing'; + --- + + {name} + +
+ + + `); + triggerFSEvent(container, fs, '/src/pages/index.astro', 'change'); + + r = createRequestAndResponse({ + method: 'GET', + url: '/', + }); + container.handle(r.req, r.res); + html = await r.text(); + $ = cheerio.load(html); + expect($('body.one')).to.have.a.lengthOf(0); + expect($('body.two')).to.have.a.lengthOf(1); + }); + }); }); diff --git a/packages/astro/test/units/test-utils.js b/packages/astro/test/units/test-utils.js index 45e1e50a97..80e2c150b9 100644 --- a/packages/astro/test/units/test-utils.js +++ b/packages/astro/test/units/test-utils.js @@ -12,15 +12,23 @@ class MyVolume extends Volume { this.#root = root; } + #forcePath(p) { + if (p instanceof URL) { + p = unixify(fileURLToPath(p)); + } + return p; + } + getFullyResolvedPath(pth) { return npath.posix.join(this.#root, pth); } existsSync(p) { - if (p instanceof URL) { - p = fileURLToPath(p); - } - return super.existsSync(p); + return super.existsSync(this.#forcePath(p)); + } + + readFile(p, ...args) { + return super.readFile(this.#forcePath(p), ...args); } writeFileFromRootSync(pth, ...rest) { @@ -44,6 +52,28 @@ export function createFs(json, root) { return fs; } +/** + * + * @param {import('../../src/core/dev/container').Container} container + * @param {typeof import('fs')} fs + * @param {string} shortPath + * @param {'change'} eventType + */ +export function triggerFSEvent(container, fs, shortPath, eventType) { + container.viteServer.watcher.emit( + eventType, + fs.getFullyResolvedPath(shortPath) + ); + + if(!fileURLToPath(container.settings.config.root).startsWith('/')) { + const drive = fileURLToPath(container.settings.config.root).slice(0, 2); + container.viteServer.watcher.emit( + eventType, + drive + fs.getFullyResolvedPath(shortPath) + ); + } +} + export function createRequestAndResponse(reqOptions = {}) { const req = httpMocks.createRequest(reqOptions);