Skip to content
Open
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/fix-actions-hmr.md
Original file line number Diff line number Diff line change
@@ -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.
42 changes: 38 additions & 4 deletions packages/astro/src/actions/vite-plugin-actions.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -51,6 +56,8 @@ export function vitePluginActionsBuild(
};
}

const ACTIONS_DIR_NAME = 'actions';

export function vitePluginActions({
fs,
settings,
Expand All @@ -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,
Expand Down Expand Up @@ -88,17 +96,43 @@ 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) {
server.restart();
}
}
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: {
Expand Down
8 changes: 8 additions & 0 deletions packages/astro/src/core/app/dev/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ export class DevApp extends BaseApp<NonRunnablePipeline> {
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.
Expand Down
7 changes: 7 additions & 0 deletions packages/astro/src/core/app/entrypoints/virtual/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions packages/astro/src/core/base-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions packages/astro/src/vite-plugin-app/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,14 @@ export class AstroServerApp extends BaseApp<RunnablePipeline> {
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();
}
Comment on lines +93 to +99
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This something that shouldn't happen in production. Clearing actions in production is incorrect.


async devMatch(pathname: string): Promise<DevMatch | undefined> {
const matchedRoute = await matchRoute(
pathname,
Expand Down
7 changes: 7 additions & 0 deletions packages/astro/src/vite-plugin-app/createAstroServerApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
43 changes: 43 additions & 0 deletions packages/astro/test/units/actions/actions-hmr.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading