Skip to content

fix(builders): skip generated writes that would not change the file - #3454

Open
josuediazflores wants to merge 1 commit into
vercel:mainfrom
josuediazflores:fix/skip-unchanged-generated-writes
Open

fix(builders): skip generated writes that would not change the file#3454
josuediazflores wants to merge 1 commit into
vercel:mainfrom
josuediazflores:fix/skip-unchanged-generated-writes

Conversation

@josuediazflores

@josuediazflores josuediazflores commented Aug 11, 2026

Copy link
Copy Markdown

Description

Fixes #3451.

The builder rewrote its generated files on every rebuild regardless of content. Those files live inside the directory the dev server watches, so a byte-identical rewrite still bumped the mtime and, via the temp-file + rename, the inode. Webpack saw a change to a file in its module graph and ran a second compilation round for a rebuild that produced nothing new.

Tapping compiler.hooks.watchRun in workbench/nextjs-webpack and reading modifiedFiles, a no-op touch of a workflow file on main gives:

round 1  nodejs  <- ["workflows/1_simple.ts"]                       # the edit
round 2  nodejs  <- ["app/.well-known/workflow/v1/flow/route.js"]   # the identical rewrite

This compares before writing and skips when the bytes already match, which removes round 2. An edit that genuinely changes the bundle is unaffected: the content differs, the write happens, and round 2 is legitimate work.

Covers the paths that bypassed writeGeneratedFile too: the manifest and its diagnostics copy, the unbundled webhook route, and the generated .gitignore / config.json / public manifest.json in the Next builder.

Two things fell out of implementing it, both worth calling out:

Nitro opts out. Its dev handler cache-busts the dynamic import of steps.mjs off the mtime of workflows.mjs (packages/nitro/src/index.ts:496, v2 branch at :520). A step-body-only edit leaves workflows.mjs byte-identical, so guarding that write freezes the version key and the handler keeps serving the previous step code until restart. I hit this with the first version of the patch. The unconditional rewrite was accidentally load-bearing. Nitro loses nothing by opting out, since it generates into <buildDir>/workflow, which no dev server watches. grep -rn "mtimeMs" packages/ says it is the only such consumer.

manifest.json is now emitted in sorted order. Entry discovery runs concurrently, so its key order varied between otherwise identical builds and it was rewritten every time regardless of the guard. Two consecutive next build --webpack runs produced deep-equal manifests with different key order in steps and workflows. This is separable from the rest if you would rather not take it.

Also worth noting: #2809 proposed this same fix in its second bullet ("skip the write when generated file content is unchanged"). Only its first bullet shipped, in #2813.

How did you test your changes?

Unit tests. New packages/builders/src/write-if-changed.test.ts, 16 tests. Includes a regression test that writeGeneratedFile leaves mtime and inode untouched on identical content, and one asserting the opted-out path still rewrites. Both were verified to fail when the guard is removed.

Workbench, Next (webpack). Two consecutive pnpm build runs in workbench/nextjs-webpack with no source change. Before: manifest.json changed sha every run and flow/route.js churned its inode. After: every generated file identical in sha, mtime, and inode.

Workbench, Next dev. The watchRun measurement above, run with and without the patch on the same app, verifying base-builder.js genuinely lacked the guard in the control. Steady state goes from 2 nodejs rounds per no-op edit to 1.

Workbench, Nitro. Two consecutive pnpm build runs in workbench/nitro-v3 confirm workflows.mjs still bumps its mtime while its sha stays constant, i.e. the opt-out preserves the pre-existing behavior the dev handler depends on.

Suites. typecheck 41/41. Unit tests across builders/next/nitro/core: 2309 passing. 15 pre-existing failures (14 in packages/core/src/vm/uint8array-base64.test.ts, 1 in node-module-esbuild-plugin.test.ts) reproduce identically on a clean tree; they look like Node 25 vs the base64 polyfill and are unrelated.

PR Checklist - Required to merge

  • 📦 pnpm changeset was run to create a changelog for this PR
  • 🔒 DCO sign-off passes (run git commit --signoff on your commits)
  • 📝 Ping @vercel/workflow in a comment once the PR is ready, and the above checklist is complete

@josuediazflores
josuediazflores requested a review from a team as a code owner August 11, 2026 08:21
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

@josuediazflores is attempting to deploy a commit to the Vercel Labs Team on Vercel.

A member of the Team first needs to authorize it.

@VaguelySerious VaguelySerious left a comment

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.

AI review: no blocking issues

* signal must override this to `false`, otherwise skipping the write also
* suppresses the signal.
*/
protected get skipsUnchangedGeneratedWrites(): boolean {

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.

AI Review: Note

This getter only governs writeGeneratedFile. Three call sites added in this same change bypass it by calling the free writeFileIfChanged directly: createManifest's manifest.json, its diagnostics copy, and createWebhookBundle's unbundled path.

Nitro reaches all three (createManifest + createWebhookBundle({ bundle: false }) in its #buildOnce), so those writes are skipped for nitro even though it returns false here. Nothing breaks today, since the mtime signal the opt-out protects is on workflows.mjs, and the webhook route content is a static string with no user code in its graph. But the getter reads like a per-target kill switch and isn't one, so the next mtime-signal consumer added on any other generated file will silently not be covered.

Either route those writes through a method that consults the flag, or narrow the doc comment to say it governs writeGeneratedFile only.

if (!bundle) {
// For Next.js, just write the unbundled file
await writeFile(outfile, routeContent);
await writeFileIfChanged(outfile, routeContent);

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.

AI Review: Nit

The comment above this line ("For Next.js, just write the unbundled file") is now misleading: nitro's #buildOnce also calls createWebhookBundle({ bundle: false }), so this branch is not Next-only. Worth dropping the framework name while you're in here.

* `<buildDir>/workflow`, which no dev server watches, so skipping the write
* would not have saved a recompile here.
*/
protected override get skipsUnchangedGeneratedWrites(): boolean {

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.

AI Review: Note

I could not reproduce the staleness this opt-out is protecting against, so flagging it in case the premise is narrower than the comment implies.

I flipped this getter to true, rebuilt, and ran three consecutive step-body-only edits against workbench/nitro-v3 dev. Fresh step code was served every time. Across each rebuild workflows.mjs kept a constant sha but got a new inode and mtime, so the dev handler's version key kept moving and the steps.mjs re-import kept firing.

Instrumenting writeGeneratedFile showed only a skip for that path on those rebuilds, while a directory watcher caught a workflows.mjs.<uuid>.tmp -> rename each time. I could not attribute that write, most likely a second builder instance whose stderr I was not capturing, so I am not claiming the opt-out is unnecessary and I would keep it: preserving the write is the conservative side and it costs nitro nothing.

Two asks: which workbench did you see the stale step code on (nitro, nitro-v2, or nitro-v3)? And if this is meant to be load-bearing, it is worth a test that fails when the getter returns true. The unit test added here asserts the mechanism (opted-out targets still rewrite) rather than the consequence, so as written nothing stops someone from deleting this override later as apparent dead weight.

export { isValidBuildTarget, validBuildTargets } from './types.js';
export { VercelBuildOutputAPIBuilder } from './vercel-build-output-api.js';
export { resolveWorkflowAliasRelativePath } from './workflow-alias.js';
export { hasSameContent, writeFileIfChanged } from './write-if-changed.js';

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.

AI Review: Note

Now that this is exported, the same identical-rewrite churn is still present in the Vite-based integrations, which write their generated routes into the watched app directory with plain writeFile:

  • packages/astro/src/builder.ts:93 (workflows route), :172 (webhook route), :57 (.gitignore)
  • packages/sveltekit/src/builder.ts:113 (workflows route), :196 (webhook route), :73 and :134 (.gitignore)

Same bug class as #3451, and those builders don't set config.watch, so they can't be distinguished from a production build by that flag. Not asking for it in this PR, the Next fix stands on its own, just worth a follow-up issue so it doesn't get lost.

'@workflow/nitro': patch
---

Keep rewriting the generated bundles even when unchanged, since the dev handler cache-busts `steps.mjs` off the mtime of `workflows.mjs` and a step-body-only edit would otherwise serve stale code.

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.

AI Review: Nit

Changeset messages land in the user-facing changelog, so internals like the mtime cache-busting read oddly there. Something closer to the user-visible effect, e.g. "Keep regenerating dev bundles so step edits are picked up without restarting the dev server", would fit better. Same for the manifest-order one, which is fine as-is but leans a little on "concurrent discovery order".

@VaguelySerious

Copy link
Copy Markdown
Member

AI Review: Note

All 7 GitHub Actions workflows are sitting at action_required on a7fe269 (fork PR), so nothing in CI has run: no Build Packages, no Unit Tests, no Lint. A maintainer needs to hit "Approve and run" before this can be judged. Fork PRs can't get working deploy credentials, so the E2E lanes will fail regardless. Judge on Build Packages + Unit Tests.

Ran locally in the meantime, all green: typecheck 14/14, biome check clean on the five changed source files (the two cognitive-complexity findings in builder-eager.ts are pre-existing warnings, exit 0), and unit tests 237 passing in builders / 35 in next / nitro.

@changeset-bot

changeset-bot Bot commented Aug 13, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 41e719f

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
Name Type
@workflow/builders Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/astro Patch
@workflow/cli Patch
@workflow/nest Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
@workflow/vite Patch
@workflow/vitest Patch
workflow Patch
@workflow/nuxt Patch
@workflow/world-testing Patch
@workflow/core Patch
@workflow/web-shared Patch
@workflow/web Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@josuediazflores

Copy link
Copy Markdown
Author

CI triage: 53 of the 57 red checks are fork-permission fallout, the other 4 reproduce on main

Head SHA 96147a8, run 31752827593.

Deploy and permission failures (53)

  • 18 Vercel – * checks: "Authorization required to deploy." Fork PR, so a Vercel Labs member has to authorize it (link is in the vercel[bot] comment above).
  • 26 E2E Vercel Prod + Multi-Region + Benchmark + Docs/Tarballs Preview Smoke: all stuck in vercel/wait-for-deployment-action waiting on a deployment that is never created, then timing out.
  • 4 comment jobs (Create PR Comment x2, Benchmark Comment, E2E Summary): Resource not accessible by integration. Fork PRs get a read-only GITHUB_TOKEN.
  • E2E Required Check: aggregation gate over the above, also failing on every recent main run.

Real test failures (4), all with precedent on main

Lane Test Error
Local Dev (turbopack, stable node) dev.test.ts > should follow Next flow-route HMR rebuild rules for body-only changes expected 5 to be 1
Local Dev (webpack, stable node) same expected 5 to be 1
Local Dev (webpack, canary quickjs) same 300s timeout, hmrFuzzAddedStep never in manifest
E2E Windows (node) dev.test.ts > should rebuild on adding workflow file 120s timeout, newWorkflowFile never generated

On main, predating this run:

  • expected 5 to be 1, same test: 31533009514, 31643295401, 31636132289. Also expected 4 to be 1 in 31636826001.
  • hmrFuzzAddedStep timeout: webpack canary lanes hit it in 8 of the last 14 main runs.
  • Windows should rebuild on adding workflow file: byte-identical failure in 31752021817, which started 25 minutes before this run.

All 14 most recent Tests runs on main are red, and three Local Dev failures in one run is within the range main already shows.

Caveat: the flaky assertion is expectHmrLogCounts, which counts exactly the rebuild signals this PR changes, so matching a known flake is strong evidence rather than proof.

Green here: Unit Tests on ubuntu and windows (including the new write-if-changed.test.ts), Biome, Vitest Plugin Tests, Build Shared E2E Packages, and ~100 other E2E lanes including all Local Prod and Local Postgres.

Ask: authorize the Vercel deployments, or re-run just the three E2E Local Dev lanes on this SHA to confirm the flake.

@VaguelySerious

Copy link
Copy Markdown
Member

@josuediazflores Could you sign your commits? Our policy requires it before merging. Easiest is to just ask your agent to squash + force push this branch content as a new signed commit

@josuediazflores
josuediazflores force-pushed the fix/skip-unchanged-generated-writes branch from 96147a8 to 037284f Compare August 14, 2026 23:36
The builder rewrote its generated files on every rebuild regardless of
content. Those files live inside the directory the dev server watches, so
a byte-identical rewrite still bumped the mtime and, via the temp-file +
rename, the inode. Webpack saw a change to a file in its module graph and
ran a second compilation round for a rebuild that produced nothing new.

Compare before writing, and skip when the bytes already match. Measured on
workbench/nextjs-webpack, a no-op rebuild goes from two compilation rounds
to one. An edit that genuinely changes the bundle is unaffected: the
content differs, so the write happens as before.

Covers the paths that bypassed the atomic write helper as well: the
manifest and its diagnostics copy, the unbundled webhook route, and the
generated .gitignore / config.json / public manifest in the Next builder.

Two things fell out of this:

Nitro opts out. Its dev handler cache-busts the dynamic import of
steps.mjs off the mtime of workflows.mjs, so a step-body-only edit leaves
workflows.mjs byte-identical and skipping that write would freeze the
version key and serve the previous step code until restart. It loses
nothing by opting out, since it generates into <buildDir>/workflow, which
no dev server watches.

manifest.json is now emitted in sorted order. Entry discovery runs
concurrently, so its key order varied between otherwise identical builds
and it was rewritten every time regardless of the guard.

Signed-off-by: josuediazflores <152042011+josuediazflores@users.noreply.github.com>
@josuediazflores
josuediazflores force-pushed the fix/skip-unchanged-generated-writes branch from 037284f to 41e719f Compare August 14, 2026 23:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dev: generated route.js is rewritten with identical content on every compile, thrashing the webpack watcher

2 participants