Skip to content

@workflow/vite dev: every deleted .ts/.js file queues a full rebuild, and queued rebuilds never merge #5123

Open

Description

@AndreasHald

Summary

In vite dev (seen with SvelteKit), the workflow:hot-update plugin starts a full builder.build() for every deleted .ts/.js file anywhere Vite watches, whether or not the file had anything to do with workflows. Builds go through createBuildQueue, which runs them one after another but never merges builds that are waiting. A branch switch or rebase that deletes many files therefore queues one full build per deleted file. Those builds then run back to back for a long time: by our measurements, deleting about 1,400 files would keep the dev server rebuilding for nearly three hours. Memory grows the whole time, and our dev server reached about 32 GB before we killed it.

Versions

  • workflow 4.8.8, @workflow/sveltekit 4.0.23, @workflow/vite 4.0.23, @workflow/builders 4.1.13
  • vite 8.1.1, @sveltejs/kit 2.63.0
  • Node 24.22.1, Linux

The code below is unchanged in the latest release, @workflow/vite and @workflow/builders 5.0.6. #4385 removed the per-environment doubling; everything else still applies.

Reproduction

  1. In a SvelteKit app using workflowPlugin() from workflow/sveltekit, start vite dev.
  2. Create 30 plain .ts files under src/lib with no directives, and let the server settle.
  3. Delete them all at once.

On 4.0.23 this runs 60 builds (2 per file, once for each Vite environment), and src/routes/.well-known/workflow/v1/flow/+server.js is rewritten every 4–5 s for about 95 s. On 5.0.6 it should be 30 builds.

Causes

1. Deleted files always rebuild

dist/hot-update.js:

try {
    content = await read();
}
catch {
    // File might have been deleted - trigger rebuild to update generated routes
    console.log('Workflow file changed, rebuilding...');
    await runBuild(() => resolvedBuilder.build());
    return;
}

When a file is deleted, Vite's read() throws ENOENT, so every deleted JS/TS file is treated as a possible workflow file. The builder could check whether the deleted path was one of the files it found during its last discovery, and rebuild only then.

2. Builds that are waiting are never merged

createBuildQueue in @workflow/builders is just queue = queue.then(task). A build that hasn't started yet already sees every change made before it starts, so later requests could join it instead of each adding another full build. #4445 reports the same pattern in @workflow/next's eager builder, and #3333 fixes it there; the Vite plugin needs the same fix.

3. Each build makes SvelteKit regenerate all route types

writeGeneratedFile writes ${target}.${uuid}.tmp next to each generated route in src/routes/.well-known/workflow/... and then renames it. SvelteKit's watcher sees a file added and removed under src/routes and runs a full manifest sync, which in our app rewrites about 700 files in .svelte-kit/types on every workflow build. That makes each build much more expensive than the build itself. Writing the temp file outside src/routes, for example in the OS temp dir or under .svelte-kit, and renaming it into place would avoid this (rename works across directories on the same filesystem).

Memory

The JS heap stays flat after forced GC (602 MB before and after 40 builds), but the process's RSS grows by about 6–7 MB per build and is never released. It is native memory, not JS. The in-process SWC transform isn't it: 1,200 isolated transforms added about 10 MB. The esbuild child process stayed at about 70 MB. We didn't track down the rest. With hundreds of queued builds, this is what took the dev server to about 32 GB.

Related

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions