Last updated on

The PayloadCMS generated file that drifted


I shipped a small feature — syntax-highlighted code blocks for this blog — and a few minutes later noticed something that had nothing to do with code blocks: a file I hadn't touched was showing up as modified in git status. Not a source file. A generated file. And it kept coming back no matter how many times I reverted it.

The chase that followed was more interesting than the feature, so here it is: a story about generated files, a diff that changed shape depending on where you ran it, and the CI check that finally pinned it down.

The symptom

The file was src/app/(payload)/admin/importMap.js. If you use Payload CMS, you know it: Payload writes an "import map" that wires every custom admin component into its bundle. You don't hand-edit it — you run payload generate:importmap and commit the result. It sits in the repo, DO NOT MODIFY header and all.

After merging the code-blocks feature to main, the committed import map no longer matched what the generator produced. The diff was a symmetric wall of moved lines:

 src/app/(payload)/admin/importMap.js | 76 ++++++++++++++++++------------------
 1 file changed, 38 insertions(+), 38 deletions(-)


Thirty-eight lines removed, thirty-eight added. That shape — equal insertions and deletions — usually means reordering, not new content. But "usually" isn't "definitely," and this is a generated file that production actually reads. I wanted to be sure.

First instinct, and why it was wrong

My first instinct, honestly, had been to ignore it. When I first saw the regenerated file during development, I decided it was meaningless churn from Payload's generator and deliberately kept it out of the feature branch. Leave the noise out of the PR, I thought.

That decision was the bug. By excluding the regenerated file, I left main in a state where its committed import map didn't match its own generator. Nothing looked broken, so the drift shipped. The lesson arrived later, but I'll state it now: a generated file is either committed correctly or it's a latent problem. There's no "harmless" stale version.

Diagnosing it properly

Before touching anything, I wanted three questions answered.

Is the generator deterministic? If running it twice gives two different files, you can never commit a stable version and the whole model is broken. I regenerated it twice from a clean tree and compared:

# two fresh regenerations, diffed against each other
diff run_B.js run_C.js   # -> identical


Byte-identical. Deterministic. Good.

Is anything actually being added or removed, or is it purely reordering? I extracted the set of imported identifiers and the set of map keys from both the committed and the regenerated file, sorted them, and diffed the sets:

# compare the SET of entries, ignoring order
comm -23 committed_keys.txt regenerated_keys.txt   # only in committed -> (empty)
comm -13 committed_keys.txt regenerated_keys.txt   # only in regenerated -> (empty)


Empty on both sides. The two files contained exactly the same entries in a different order. So nothing was missing — the admin UI wasn't going to lose a component — it was cosmetic.

Was it in sync before my change? I checked out the commit before the feature, regenerated, and compared to what was committed there. It matched. So the import map had been correct, and adding my feature is what reordered it.

That last point is the crux. My feature registered a new editor block. That changed the input to a deterministic generator, so the output legitimately changed — even though the net set of components was identical (the one my block relies on was already pulled in elsewhere). The reordering wasn't a Payload quirk to be waved away. It was the correct, expected consequence of my change, and it belonged in my commit.

The fix, part one

The immediate fix was boring, which is how you want fixes to be: regenerate the file on main, commit it. Because generation is deterministic, once the committed file matches the generator, it stays matched until the next config change.

Which raised the real question.

Will this just happen again?

Committing the file fixes today. It does nothing for tomorrow. The underlying gap was still wide open:

  • next build doesn't regenerate these files — production reads whatever was committed.
  • Nothing in the project checked that the committed copy was current.
  • The exact same trap applies to payload-types.ts, the other file Payload generates from config.

So the next time anyone adds a block, a collection, or a custom admin component and forgets to run the generator, the repo drifts again, silently, and the only signal is a stray diff someone eventually notices.

The prevention is a CI check that regenerates the files and fails if the committed versions are stale:

- name: Regenerate import map and types
  run: |
    pnpm generate:importmap
    pnpm generate:types


- name: Fail if committed generated files are stale
  run: |
    if ! git diff --exit-code -- \
      'src/app/(payload)/admin/importMap.js' \
      'src/payload-types.ts'; then
      echo '::error::Generated files are out of date. Run the generators and commit the result.'
      exit 1
    fi


Simple idea: regenerate, then git diff --exit-code. If regeneration changed anything, the working tree is dirty, the diff fails, and the build goes red with a message telling you which command to run. I opened it as a PR — and because a PR runs its own workflow, the check would validate itself against the very files I'd just committed.

The failure that taught me something

It went red immediately. And the failure was the most useful part of the whole exercise.

CI's regenerated import map was missing an entry my committed file had:

-import { VercelBlobClientUploadHandler } from '@payloadcms/storage-vercel-blob/client'


My local file had the Vercel Blob upload handler. CI's freshly generated one didn't. Same command, same repo, same locked dependencies — different output. So much for "deterministic."

Except it was deterministic. It just wasn't a pure function of the source code. It was a function of the source code and the environment, because of this in the Payload config:

plugins: process.env.BLOB_READ_WRITE_TOKEN
  ? [vercelBlobStorage({ /* ... */ })]
  : []


The blob storage plugin — which contributes an admin component to the import map — only switches on when BLOB_READ_WRITE_TOKEN is present. My local .env has that token. Production (Vercel) has it. A bare CI runner does not. So CI built a different config, and honestly generated a different, smaller import map.

This reframed everything. The committed import map isn't just "the generated file" — it's the production-shaped generated file, and my guard was regenerating it under the wrong environment. The committed file was right. The check was wrong.

The token-shaped twist

The fix was to give CI the same plugin set production has — set the token during generation. My first attempt used an obvious placeholder:

BLOB_READ_WRITE_TOKEN: dummy-token-for-import-map-generation


Which blew up:

Error: Invalid token format for Vercel Blob adapter.
Should be vercel_blob_rw_<store_id>_<random_string>.


The plugin validates the token's format at config-build time and parses a store id out of it. It never uses it to connect during generation — but it insists the shape is right. A peek at the plugin source gave the exact regex:

options.token?.match(/^vercel_blob_rw_([a-z\d]+)_[a-z\d]+$/i)


Alphanumeric segments only — no hyphens, which is why my placeholder failed. A format-valid but entirely fake token satisfies the validator, enables the plugin, and produces the production-shaped map without ever touching a real blob store:

env:
  BLOB_READ_WRITE_TOKEN: vercel_blob_rw_ci0000000000000000_0000000000000000000000000000


Green. And that green also quietly settled a worry I'd been carrying — that generation might order things differently on Ubuntu than on my Mac. It doesn't: CI's output was byte-identical to the committed file.

What I took away

Three things stuck with me:

  1. A stale generated file is never "harmless churn." If it's committed, it's part of your source of truth. Excluding it from a commit doesn't remove the problem, it hides it. Regenerate it and commit it, every time.
  2. Deterministic doesn't mean environment-independent. A generator can be perfectly reproducible and still produce different output in CI, because its real inputs include env vars, feature flags, and conditionally-loaded plugins. If a committed artifact is environment-shaped, your verification has to reproduce that environment — deliberately.
  3. The check that fails loudly is worth more than the fix. The one-line resync solved the visible symptom. The CI guard is what actually closed the gap — and its failure, not its success, is what taught me the file was environment-dependent in the first place. A good red build is a diagnostic tool.

The feature that started all this was syntax-highlighted code blocks — the very thing rendering the snippets you just read. Fitting that the first thing it broke was a file about wiring up components, and the fix was teaching CI to notice.