diff --git a/.changeset/single-env-hot-rebuild.md b/.changeset/single-env-hot-rebuild.md new file mode 100644 index 0000000000..0816c9aaa6 --- /dev/null +++ b/.changeset/single-env-hot-rebuild.md @@ -0,0 +1,5 @@ +--- +'@workflow/vite': patch +--- + +Rebuild workflows once per file change instead of once per Vite environment. diff --git a/packages/vite/package.json b/packages/vite/package.json index 4fa3c14bb8..5e47559f11 100644 --- a/packages/vite/package.json +++ b/packages/vite/package.json @@ -26,7 +26,8 @@ "build": "tsc", "dev": "tsc --watch", "clean": "tsc --build --clean && rm -rf dist", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run src" }, "dependencies": { "@workflow/builders": "workspace:*" @@ -34,6 +35,7 @@ "devDependencies": { "@types/node": "catalog:", "@workflow/tsconfig": "workspace:*", - "vite": "7.3.6" + "vite": "7.3.6", + "vitest": "catalog:" } } diff --git a/packages/vite/src/hot-update.test.ts b/packages/vite/src/hot-update.test.ts new file mode 100644 index 0000000000..02b0a89cd2 --- /dev/null +++ b/packages/vite/src/hot-update.test.ts @@ -0,0 +1,84 @@ +import type { BaseBuilder } from '@workflow/builders'; +import type { HotUpdateOptions } from 'vite'; +import { describe, expect, it, vi } from 'vitest'; +import { workflowHotUpdatePlugin } from './hot-update.js'; + +const WORKFLOW_SOURCE = `export async function flow() {\n 'use workflow';\n}\n`; + +function setup() { + const build = vi.fn(async () => {}); + const plugin = workflowHotUpdatePlugin({ + builder: { build } as unknown as BaseBuilder, + }); + const hotUpdate = plugin.hotUpdate as ( + ctx: HotUpdateOptions + ) => Promise; + const change = (file: string, timestamp: number) => + ({ + type: 'update', + file, + timestamp, + modules: [], + read: async () => WORKFLOW_SOURCE, + server: {}, + }) as unknown as HotUpdateOptions; + return { build, hotUpdate, change }; +} + +describe('workflowHotUpdatePlugin', () => { + it('rebuilds once per file change, not once per Vite environment', async () => { + const { build, hotUpdate, change } = setup(); + + // Vite invokes `hotUpdate` for `client`, `ssr`, then each remaining + // environment (e.g. Nitro's), all with the same change context. + for (let environment = 0; environment < 3; environment++) { + await hotUpdate(change('/app/workflows/flow.ts', 1_000)); + } + + expect(build).toHaveBeenCalledOnce(); + }); + + it('rebuilds again for a later change to the same file', async () => { + const { build, hotUpdate, change } = setup(); + + await hotUpdate(change('/app/workflows/flow.ts', 1_000)); + await hotUpdate(change('/app/workflows/flow.ts', 1_000)); + await hotUpdate(change('/app/workflows/flow.ts', 2_000)); + + expect(build).toHaveBeenCalledTimes(2); + }); + + it('rebuilds once per change when changes to different files interleave', async () => { + const { build, hotUpdate, change } = setup(); + + // Vite handles each file's change concurrently, so one file's `ssr` call + // can land between another file's `client` and `ssr` calls. + await hotUpdate(change('/app/workflows/a.ts', 1_000)); + await hotUpdate(change('/app/workflows/b.ts', 1_001)); + await hotUpdate(change('/app/workflows/a.ts', 1_000)); + await hotUpdate(change('/app/workflows/b.ts', 1_001)); + + expect(build).toHaveBeenCalledTimes(2); + }); + + it('rebuilds once per change when changes to the same file interleave', async () => { + const { build, hotUpdate, change } = setup(); + + // A second write can arrive before the first change reaches `ssr`. + await hotUpdate(change('/app/workflows/a.ts', 1_000)); + await hotUpdate(change('/app/workflows/a.ts', 1_050)); + await hotUpdate(change('/app/workflows/a.ts', 1_000)); + await hotUpdate(change('/app/workflows/a.ts', 1_050)); + + expect(build).toHaveBeenCalledTimes(2); + }); + + it('rebuilds for a change to a different file', async () => { + const { build, hotUpdate, change } = setup(); + + await hotUpdate(change('/app/workflows/a.ts', 1_000)); + await hotUpdate(change('/app/workflows/b.ts', 1_000)); + + expect(build).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/vite/src/hot-update.ts b/packages/vite/src/hot-update.ts index e00b39da3e..befbb312f9 100644 --- a/packages/vite/src/hot-update.ts +++ b/packages/vite/src/hot-update.ts @@ -18,6 +18,13 @@ interface WorkflowHotUpdatePluginOptions { enqueue?: (fn: () => Promise) => Promise; } +/** + * Changes remembered for per-environment deduplication. Only a change still + * being delivered to its remaining environments needs remembering, so this + * only has to outlast a burst of concurrent edits. + */ +const MAX_REMEMBERED_CHANGES = 256; + /** * Vite plugin that watches for workflow/step file changes and triggers rebuilds. * @@ -33,6 +40,29 @@ export function workflowHotUpdatePlugin( // Default enqueue just runs the function directly const runBuild = enqueue ?? ((fn: () => Promise) => fn()); + // Vite calls `hotUpdate` once per environment (`client`, `ssr`, and any a + // framework adds, such as Nitro's `nitro`) for the same file change, awaiting + // each call before the next and before sending any HMR update. The calls + // share `timestamp`. The builder output does not depend on the environment, + // so one change needs one rebuild; rebuilding per environment multiplies + // every edit's rebuild time by the environment count. Changes are handled + // concurrently, so the environment calls for one change can interleave with + // those for another change to the same or a different file: remember each + // change, not just the latest one. + const handledChanges = new Set(); + const isRepeatDelivery = ({ file, timestamp }: HotUpdateOptions) => { + const change = `${timestamp}:${file}`; + if (handledChanges.has(change)) { + return true; + } + handledChanges.add(change); + if (handledChanges.size > MAX_REMEMBERED_CHANGES) { + // Sets iterate in insertion order, so this evicts the oldest change. + handledChanges.delete(handledChanges.values().next().value as string); + } + return false; + }; + return { name: 'workflow:hot-update', async hotUpdate(ctx: HotUpdateOptions) { @@ -58,6 +88,10 @@ export function workflowHotUpdatePlugin( return; } + if (isRepeatDelivery(ctx)) { + return; + } + // Read the file to check for workflow/step directives let content: string; try { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c9574bb3c..1cc77b42b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -789,6 +789,9 @@ importers: vite: specifier: 7.3.6 version: 7.3.6(@types/node@22.19.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) + vitest: + specifier: 'catalog:' + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.19.0)(@vitest/coverage-v8@4.1.10)(jsdom@26.1.0)(vite@7.3.6(@types/node@22.19.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) packages/vitest: dependencies: