Skip to content

fix(core): save a blank image or file field value as null - #3129

Merged
danielmlr merged 5 commits into
emdash-cms:mainfrom
eisenbruch:fix/media-empty-string
Oct 24, 2026
Merged

danielmlr merged 5 commits into
emdash-cms:mainfrom
eisenbruch:fix/media-empty-string

Conversation

@eisenbruch

@eisenbruch eisenbruch commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

What does this PR do?

Saving an entry whose image or file field holds an empty string ("") rewrites the field to { "provider": "external", "id": "", "src": "" }. Nothing visible changes, since both render as no image, but every save of such an entry changes its stored data, which shows up as churn in exports, diffs and sync jobs. On our site that was most of ~9,700 directory listings, whose imported logo field is "" whenever a business has no logo.

No existing issue.

Cause

normalizeMediaValue() (src/media/normalize.ts) sends any string to normalizeStringUrl(). An empty string is not an internal media URL, not an http(s) URL, and not a local media id, so it reaches the "unrecognized string, treat as external" fallback and comes back as an external media object with an empty src.

Fix

  • normalizeMediaValue() returns null for an empty or whitespace-only string, before any provider lookup. The same path covers darkVariant, where a blank string now drops the variant instead of attaching an empty external object.
  • EmDashRuntime.normalizeMediaFields() writes null for a blank top-level image or file value, and for a blank image inside a repeater item. It used to assign only non-null results, which would have left "" in place, and validateContentData rejects "" for these fields (the review on this PR caught that). null is the canonical empty value and the field schema accepts it, so an existing "" becomes null on its next save and then stays stable.

Every other string input is unchanged.

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes (packages/core, after pnpm build at the root)
  • pnpm lint passes (pnpm lint:json: 0 diagnostics)
  • pnpm test passes (or targeted tests for my change): tests/unit/media, tests/unit/fields, tests/integration/content/repeater-media-normalize.test.ts and the new tests/integration/content/media-empty-string.test.ts, 21 files, 213 tests
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Not applicable.
  • I have added and reviewed the user-facing changeset (emdash: patch)
  • New features link to an approved Discussion. Not applicable.
  • I have included screenshots below if this PR changes the UI. Not applicable.

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 5 (Claude Code)

Screenshots / test output

Not applicable for UI.

Unit test in tests/unit/media/normalize.test.ts: for "" and " " the result is null and the local provider is never asked.

Integration test in tests/integration/content/media-empty-string.test.ts. It creates an entry with a blank image, a whitespace-only file and a blank repeater image, and it updates an existing entry with a blank image. Without the runtime change:

 × saves blank image, file and repeater image values as null
 × updates an entry whose media field is blank
VALIDATION_ERROR: hero: Invalid input: expected object, received string; attachment: Invalid input: expected object, received string; gallery.0.image: Invalid input: expected object, received string
      Tests  2 failed (2)

With it:

 Test Files  21 passed (21)
      Tests  213 passed (213)

@changeset-bot

changeset-bot Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 721f201

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

This PR includes changesets to release 12 packages
Name Type
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/plugin-test Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/admin Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
create-emdash Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds 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

@pkg-pr-new

pkg-pr-new Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/admin@3129

@emdash-cms/auth

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/auth@3129

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/auth-atproto@3129

@emdash-cms/blocks

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/blocks@3129

@emdash-cms/cloudflare

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/cloudflare@3129

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/contentful-to-portable-text@3129

emdash

npm i https://pkg.pr.new/emdash-cms/emdash@3129

create-emdash

npm i https://pkg.pr.new/emdash-cms/emdash/create-emdash@3129

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/gutenberg-to-portable-text@3129

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-cli@3129

@emdash-cms/plugin-test

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-test@3129

@emdash-cms/plugin-types

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-types@3129

@emdash-cms/registry-client

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-client@3129

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-lexicons@3129

@emdash-cms/registry-loader

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-loader@3129

@emdash-cms/registry-moderation

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-moderation@3129

@emdash-cms/registry-verification

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-verification@3129

upgrade-emdash

npm i https://pkg.pr.new/emdash-cms/emdash/upgrade-emdash@3129

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/sandbox-workerd@3129

@emdash-cms/x402

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/x402@3129

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-ai-moderation@3129

@emdash-cms/plugin-ai-search

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-ai-search@3129

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-atproto@3129

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-audit-log@3129

@emdash-cms/plugin-color

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-color@3129

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-embeds@3129

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-field-kit@3129

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-forms@3129

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-webhook-notifier@3129

commit: 721f201

@eisenbruch
eisenbruch marked this pull request as ready for review September 15, 2026 17:14
@github-actions github-actions Bot added the review/needs-review No maintainer or bot review yet label Sep 15, 2026
emdashbot[bot]
emdashbot Bot previously requested changes Sep 15, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix’s intent is right: normalizeMediaValue should not turn "" into a fake { provider: "external", id: "", src: "" } object. However, returning null is only half the story. The runtime then relies on if (normalized) result[field.slug] = normalized to leave the value untouched, so an empty string stays "" in processedData. validateContentData validates image/file fields against a generated Zod schema that expects a MediaValue object (or null/undefined for optional fields) and does not accept "". Because validation runs right after normalizeMediaFields, saving an entry whose media field is currently "" will now fail with VALIDATION_ERROR rather than silently persisting.

This is a concrete regression for the exact data the PR describes — optional imported image/file fields containing "" — so I don’t think it should merge as-is.

The two reasonable fixes are:

  1. Canonicalize blank strings to null in the save pipeline. This is the smallest correct change: null is already the canonical "no media" shape and the schema accepts it. It means existing "" values migrate to null on the next save, but after that they are stable and no longer churn.
  2. Make validateContentData/generateZodSchema accept "" for image/file fields. This preserves the raw "" byte-for-byte, but it widens the field type and may require other consumers to tolerate the string.

I lean toward option 1 because it keeps the field’s canonical type honest and avoids a one-off string value lingering in image/file columns.

I checked the diff, the changed unit test, the runtime save/validation flow, the generated content schema, the WordPress import rewrite path, and the changeset. The unit test correctly covers normalizeMediaValue, but there is no save/validation round-trip test proving that a blank media field can actually be persisted after this change. Whichever fix is chosen, an integration test should exercise that path.

Comment thread packages/core/src/media/normalize.ts Resolved
@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-review No maintainer or bot review yet labels Sep 15, 2026
@eisenbruch eisenbruch changed the title fix(core): leave an empty media field value unchanged on save fix(core): save a blank image or file field value as null Sep 15, 2026
@github-actions github-actions Bot added size/M review/needs-rereview New commits since the last review and removed size/S review/awaiting-author Reviewed; waiting on the author to respond labels Sep 15, 2026
@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Sep 16, 2026
@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Sep 16, 2026
@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Sep 20, 2026
@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Sep 20, 2026
@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Sep 21, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The approach is the right one: null is already the canonical empty value for image/file fields, and the save pipeline should canonicalize blank strings to null so existing "" values migrate and stop churning on every save.

The re-review confirms the prior requested changes are addressed:

  • normalizeMediaValue() now returns null for empty/whitespace-only strings before any provider lookup.
  • EmDashRuntime.normalizeMediaFields() now writes null for blank top-level image/file values and for blank images inside repeater items, so the validator no longer sees a raw "".
  • A new integration test (media-empty-string.test.ts) exercises the create/update round-trip through handleContentCreate/handleContentUpdate and asserts the stored values become null.

I checked the diff, normalizeMediaFields, the validation/schema pipeline, the content repository serialization, the dark-variant normalizer, the WordPress rewrite path, and the changeset. The only remaining issue is a new inline comment that justifies the decision instead of explaining a non-obvious invariant.

Comment thread packages/core/src/emdash-runtime.ts Resolved
@emdashbot emdashbot Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed bot:review Trigger an emdashbot code review on this PR review/needs-rereview New commits since the last review labels Sep 21, 2026
@eisenbruch

Copy link
Copy Markdown
Contributor Author

Dropped in d66aaf8. The isBlankString(value) branch says what it does, so the comment was only restating the decision.

No logic change; oxfmt --check is clean across the tree.

@github-actions github-actions Bot added review/needs-rereview New commits since the last review and removed review/awaiting-author Reviewed; waiting on the author to respond labels Sep 21, 2026
@eisenbruch
eisenbruch force-pushed the fix/media-empty-string branch from 13cbc16 to c86d275 Compare October 2, 2026 17:23
@github-actions github-actions Bot added the review/needs-rereview New commits since the last review label Oct 2, 2026
@eisenbruch

Copy link
Copy Markdown
Contributor Author

Rebased onto current main and pushed c86d275d, taking both points from the latest review.

Blank media inside a block. The block branch of normalizeFieldValues() now writes null for a blank image or file value, and for a blank image in a repeater inside a block, the same as the top-level branches. One note so the behaviour is not overstated: on create and update normalizeBlocksData() validates block values before normalizeFieldValues() runs (emdash-runtime.ts, the calls sit one after the other in both handlers), so a blank string in a block's image field is rejected there with VALIDATION_ERROR before this code sees it. That is what main did without this PR when I reproduced it on September 26, and the order of the two calls has not changed since. Making the block validator accept a blank string is a separate change and is not in this PR.

Changeset. Narrowed to what the code does: "A blank image or file field, or a blank image inside a repeater, is now saved as null". A repeater cannot hold a file sub-field (REPEATER_SUB_FIELD_TYPES has no file, and creating one throws), so there is no file case to add to the repeater loop.

Checked on the rebased branch: media-empty-string.test.ts, normalize.test.ts and blank-array-field.test.ts pass (37 tests); tsgo --noEmit, oxfmt --ignore-path .gitignore --check and oxlint --type-aware --deny-warnings are clean.

@github-actions github-actions Bot removed the review/awaiting-author Reviewed; waiting on the author to respond label Oct 2, 2026
@github-actions github-actions Bot added review/needs-review No maintainer or bot review yet and removed review/needs-rereview New commits since the last review labels Oct 9, 2026
Fixes saving empty image or file fields to return null instead of an object. Updates validation for required fields to prevent saving empty values.

@danielmlr danielmlr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for this fix!
I added one sentence to the changeset. It says that a required image or file field now returns a validation error for "", where it used to save an empty image.

@emdashbot emdashbot Bot added review/needs-review No maintainer or bot review yet and removed review/needs-review No maintainer or bot review yet labels Oct 24, 2026
@github-actions github-actions Bot added review/needs-rereview New commits since the last review and removed review/needs-review No maintainer or bot review yet labels Oct 24, 2026
@emdashbot emdashbot Bot added review/needs-rereview New commits since the last review and removed review/needs-rereview New commits since the last review labels Oct 24, 2026
@github-actions github-actions Bot added review/approved Approved; no new commits since and removed review/needs-rereview New commits since the last review labels Oct 24, 2026
@danielmlr danielmlr added the bot:review Trigger an emdashbot code review on this PR label Oct 24, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Canonicalizing blank image/file strings to null at save time is the right approach for the export/sync churn. The top-level image/file and repeater-image changes address the original failure, and the changeset now matches the narrower repeater scope and mentions the required-field change.

The reviewers disagree about the block-field handling. One reviewer judged the new block fallback fixed and approved, noting only that no test covers it. The other found that block validation (normalizeBlocksData() → normalizeBlockArray() → blockSchema(...).safeParse) runs before normalizeFieldValues(), so blank block media is rejected with VALIDATION_ERROR before the new fallback can run. That would make the fix ineffective for blocks. I kept this as needs_fixing, since an untested block path with a possible upstream rejection is the riskiest part of the change.

The earlier request for repeater file sub-fields was withdrawn by the reviewer who made it, because RepeaterSubField does not support files. I did not carry it forward. Nothing was run; both reviews were static.


Reviewed by anthropic/claude-opus-5-5 and openai/gpt-6.1-sol.

Comment thread packages/core/src/emdash-runtime.ts Resolved
Comment thread packages/core/src/emdash-runtime.ts Resolved
@emdashbot emdashbot Bot added review/approved Approved; no new commits since and removed bot:review Trigger an emdashbot code review on this PR review/approved Approved; no new commits since labels Oct 24, 2026
@danielmlr
danielmlr dismissed emdashbot[bot]’s stale review October 24, 2026 19:50

Addressed in later commits.

@danielmlr
danielmlr merged commit a6f4091 into emdash-cms:main Oct 24, 2026
72 of 74 checks passed
@emdashbot emdashbot Bot mentioned this pull request Oct 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants