#3163·emdash

A blank string stored in a portableText field makes the entry unsaveable from the admin

Author: eisenbruchCreated Sep 16, 2026Updated Sep 16, 2026
Labelsbot:needs-attentionbot:task

What happens

An entry whose rich text field stores "" cannot be saved. Every save is refused with:

body: Invalid input: expected array, received string

An image or file field in the same state saves fine, because normalizeMediaFields rewrites the value before validation runs. Nothing does that for portableText, so the blank string reaches validateContentData and fails there.

This traps an editor, which is why I am reporting it rather than leaving it. The admin reads the entry, gets "" back for the field, and sends it again on save. The error names a field the editor never touched and cannot see anything wrong with, and no edit made in the admin changes the value that gets sent, so there is no way out from the interface. Clearing it needs an API call sending an explicit null, which is accepted.

Blank strings get in through imports. Ours came from a Joomla migration where an empty column became "" rather than a missing key.

Reproduction

Against main (79c69b2), as an integration test in packages/core/tests/integration/content/:

typescript
beforeEach(async () => {
	ctx = await setupForDialect(dialect);
	const registry = new SchemaRegistry(ctx.db);
	await registry.createCollection({ slug: "posts", label: "Posts" });
	await registry.createField("posts", { slug: "title", label: "Title", type: "string" });
	await registry.createField("posts", { slug: "body", label: "Body", type: "portableText" });
	await registry.createField("posts", { slug: "hero", label: "Hero", type: "image" });
	runtime = createTestRuntime(ctx.db);
	const created = await runtime.handleContentCreate("posts", {
		slug: "p1",
		data: { title: "p1" },
		status: "published",
	});
	if (!created.success) throw new Error("setup failed");
	id = created.data.item.id;
});

it("an image field accepts a blank string", async () => {
	const r = await runtime.handleContentUpdate("posts", id, { data: { title: "a", hero: "" } });
	expect(r.success).toBe(true); // passes
});

it("a portableText field does not", async () => {
	const r = await runtime.handleContentUpdate("posts", id, { data: { title: "b", body: "" } });
	expect(r.success).toBe(false); // passes: "body: Invalid input: expected array, received string"
});

Both assertions pass on main, which is to say the asymmetry is real: the same empty value is tolerated in one field type and rejected in the other.

Suggested fix

Treat a blank or whitespace-only string as "no value" for portableText on the way in, the same way #3129 does for media, so it is stored as null rather than refused. That also heals entries already carrying one, since the next save writes the canonical empty value.

This is the same underlying bug as #3129 in a different field type, so it may be worth fixing in the same place. I am happy to open a PR if you would like one; I have not assumed the shape you would prefer.

Scope

Worth checking whether any other field type validates a stored value that no normalizer canonicalises first. I only checked media and rich text.

Environment

  • EmDash 0.38.0, reproduced against main at 79c69b2
  • Cloudflare Workers and D1, and the sqlite test dialect above
  • Found on a site of about 9,700 directory listings and 2,000 articles migrated from Joomla; one entry was affected