Skip to content
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/single-env-hot-rebuild.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@workflow/vite': patch
---

Rebuild workflows once per file change instead of once per Vite environment.
6 changes: 4 additions & 2 deletions packages/vite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,16 @@
"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:*"
},
"devDependencies": {
"@types/node": "catalog:",
"@workflow/tsconfig": "workspace:*",
"vite": "7.3.6"
"vite": "7.3.6",
"vitest": "catalog:"
}
}
84 changes: 84 additions & 0 deletions packages/vite/src/hot-update.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>;
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);
});
});
34 changes: 34 additions & 0 deletions packages/vite/src/hot-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ interface WorkflowHotUpdatePluginOptions {
enqueue?: (fn: () => Promise<void>) => Promise<void>;
}

/**
* 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.
*
Expand All @@ -33,6 +40,29 @@ export function workflowHotUpdatePlugin(
// Default enqueue just runs the function directly
const runBuild = enqueue ?? ((fn: () => Promise<void>) => 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<string>();
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) {
Expand All @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading