diff --git a/.changeset/fix-actions-hmr.md b/.changeset/fix-actions-hmr.md new file mode 100644 index 000000000000..af65e3f7ea7a --- /dev/null +++ b/.changeset/fix-actions-hmr.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes HMR for action files during development. Editing files in `src/actions/` now takes effect on the next request without requiring a dev server restart. diff --git a/packages/astro/src/actions/vite-plugin-actions.ts b/packages/astro/src/actions/vite-plugin-actions.ts index 68a5d3053502..36c9668ab0ae 100644 --- a/packages/astro/src/actions/vite-plugin-actions.ts +++ b/packages/astro/src/actions/vite-plugin-actions.ts @@ -1,5 +1,10 @@ +import { fileURLToPath } from 'node:url'; import type fsMod from 'node:fs'; -import type { Plugin as VitePlugin } from 'vite'; +import { + normalizePath as viteNormalizePath, + type ViteDevServer, + type Plugin as VitePlugin, +} from 'vite'; import type { BuildInternals } from '../core/build/internal.js'; import type { StaticBuildOptions } from '../core/build/types.js'; import { shouldAppendForwardSlash } from '../core/build/util.js'; @@ -51,6 +56,8 @@ export function vitePluginActionsBuild( }; } +const ACTIONS_DIR_NAME = 'actions'; + export function vitePluginActions({ fs, settings, @@ -59,6 +66,7 @@ export function vitePluginActions({ settings: AstroSettings; }): VitePlugin { let resolvedActionsId: string; + const normalizedSrcDir = viteNormalizePath(fileURLToPath(settings.config.srcDir)); return { name: VIRTUAL_MODULE_ID, @@ -88,9 +96,9 @@ export function vitePluginActions({ } }, }, - async configureServer(server) { + async configureServer(server: ViteDevServer) { const filePresentOnStartup = await isActionsFilePresent(fs, settings.config.srcDir); - // Watch for the actions file to be created. + // Watch for the actions file to be created or deleted. async function watcherCallback() { const filePresent = await isActionsFilePresent(fs, settings.config.srcDir); if (filePresentOnStartup !== filePresent) { @@ -98,7 +106,33 @@ export function vitePluginActions({ } } server.watcher.on('add', watcherCallback); - server.watcher.on('change', watcherCallback); + + server.watcher.on('change', (path) => { + watcherCallback(); + + const normalizedPath = viteNormalizePath(path); + // Check if the changed file is under the actions directory + if (!normalizedPath.startsWith(normalizedSrcDir)) return; + const relativePath = normalizedPath.slice(normalizedSrcDir.length); + if (!relativePath.startsWith(`${ACTIONS_DIR_NAME}/`)) return; + + for (const name of [ + ASTRO_VITE_ENVIRONMENT_NAMES.ssr, + ASTRO_VITE_ENVIRONMENT_NAMES.astro, + ] as const) { + const environment = server.environments[name]; + if (!environment) continue; + + const virtualMod = environment.moduleGraph.getModuleById( + ACTIONS_RESOLVED_ENTRYPOINT_VIRTUAL_MODULE_ID, + ); + if (virtualMod) { + environment.moduleGraph.invalidateModule(virtualMod); + } + + environment.hot.send('astro:actions-updated', {}); + } + }); }, load: { filter: { diff --git a/packages/astro/src/core/app/dev/app.ts b/packages/astro/src/core/app/dev/app.ts index d0eec552f51d..95e4035c033f 100644 --- a/packages/astro/src/core/app/dev/app.ts +++ b/packages/astro/src/core/app/dev/app.ts @@ -40,6 +40,14 @@ export class DevApp extends BaseApp { this.pipeline.clearMiddleware(); } + /** + * Clears the cached actions so they are re-resolved on the next request. + * Called via HMR when action files change. + */ + clearActions(): void { + this.pipeline.clearActions(); + } + /** * Updates the routes list when files change during development. * Called via HMR when new pages are added/removed. diff --git a/packages/astro/src/core/app/entrypoints/virtual/dev.ts b/packages/astro/src/core/app/entrypoints/virtual/dev.ts index dcc9bd2ab5c3..0bb1261644d8 100644 --- a/packages/astro/src/core/app/entrypoints/virtual/dev.ts +++ b/packages/astro/src/core/app/entrypoints/virtual/dev.ts @@ -42,6 +42,13 @@ export const createApp: CreateApp = ({ streaming } = {}) => { if (!currentDevApp) return; currentDevApp.clearMiddleware(); }); + + // Listen for action file changes via HMR. + // Clear the cached actions so they are re-resolved on the next request. + import.meta.hot.on('astro:actions-updated', () => { + if (!currentDevApp) return; + currentDevApp.clearActions(); + }); } return currentDevApp; diff --git a/packages/astro/src/core/base-pipeline.ts b/packages/astro/src/core/base-pipeline.ts index 44ef56ccff65..eb93bc86ef2d 100644 --- a/packages/astro/src/core/base-pipeline.ts +++ b/packages/astro/src/core/base-pipeline.ts @@ -300,6 +300,14 @@ export abstract class Pipeline { this.resolvedMiddleware = undefined; } + /** + * Clears the cached actions so they are re-resolved on the next request. + * Called via HMR when action files change during development. + */ + clearActions() { + this.resolvedActions = undefined; + } + /** * Resolves the logger destination from the manifest and updates the pipeline logger. * If the user configured `experimental.logger`, the bundled logger factory is loaded diff --git a/packages/astro/src/vite-plugin-app/app.ts b/packages/astro/src/vite-plugin-app/app.ts index 0a401aff78e5..6202892fd7ca 100644 --- a/packages/astro/src/vite-plugin-app/app.ts +++ b/packages/astro/src/vite-plugin-app/app.ts @@ -90,6 +90,14 @@ export class AstroServerApp extends BaseApp { this.pipeline.clearMiddleware(); } + /** + * Clears the cached actions so they are re-resolved on the next request. + * Called via HMR when action files change. + */ + clearActions(): void { + this.pipeline.clearActions(); + } + async devMatch(pathname: string): Promise { const matchedRoute = await matchRoute( pathname, diff --git a/packages/astro/src/vite-plugin-app/createAstroServerApp.ts b/packages/astro/src/vite-plugin-app/createAstroServerApp.ts index cba714bc2a85..43d26bd7c92b 100644 --- a/packages/astro/src/vite-plugin-app/createAstroServerApp.ts +++ b/packages/astro/src/vite-plugin-app/createAstroServerApp.ts @@ -81,6 +81,13 @@ export default async function createAstroServerApp( app.clearMiddleware(); actualLogger.debug('router', 'Middleware cache cleared due to file change'); }); + + // Listen for action file changes via HMR. + // Clear the cached actions so they are re-resolved on the next request. + import.meta.hot.on('astro:actions-updated', () => { + app.clearActions(); + actualLogger.debug('router', 'Actions cache cleared due to file change'); + }); } return { diff --git a/packages/astro/test/units/actions/actions-hmr.test.ts b/packages/astro/test/units/actions/actions-hmr.test.ts new file mode 100644 index 000000000000..033944a421c2 --- /dev/null +++ b/packages/astro/test/units/actions/actions-hmr.test.ts @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { createBasicPipeline } from '../test-utils.ts'; + +describe('actions HMR cache invalidation', () => { + it('clearActions() resets the cached resolved actions', async () => { + const firstActions = { server: { greet: () => 'hello' } } as any; + const secondActions = { server: { greet: () => 'updated' } } as any; + + let callCount = 0; + const pipeline = createBasicPipeline({ + manifest: { + actions: () => { + callCount++; + return callCount === 1 ? firstActions : secondActions; + }, + }, + } as any); + + // First call should invoke the factory and cache the result. + const result1 = await pipeline.getActions(); + assert.equal(callCount, 1); + assert.equal(result1, firstActions); + + // Subsequent call should return the cached value, not call the factory again. + const result2 = await pipeline.getActions(); + assert.equal(callCount, 1, 'factory should not be called again while cached'); + assert.equal(result2, firstActions); + + // After clearing, the next call should invoke the factory again. + pipeline.clearActions(); + const result3 = await pipeline.getActions(); + assert.equal(callCount, 2, 'factory should be called again after clearActions()'); + assert.equal(result3, secondActions); + }); + + it('getActions() returns NOOP when no actions factory is configured', async () => { + const pipeline = createBasicPipeline(); + const result = await pipeline.getActions(); + assert.ok(result, 'should return a non-undefined value'); + assert.deepEqual(result.server, {}, 'should return noop actions with empty server'); + }); +});