[Emdash] How an Unchecked Empty Submit and a Falsy-ID Guard in convertPTBlock Created a Permanent Autosave-Erasure Loop for YouTube Embeds
How an Unchecked Empty Submit and a Falsy-ID Guard in convertPTBlock Created a Permanent Autosave-Erasure Loop for YouTube Embeds
I spent a while debugging why YouTube blocks would disappear from my blog posts. I’d type `/youtube` in the EmDash editor, paste a URL, save — and after the page reloaded, the embed was gone. Not just from the rendered page; gone from the editor too, as if I’d never added it.
The database showed no `_type: "youtube"` block anywhere. This details the bug, tracing the full chain of failure, and the fix.
The Setup
My site uses EmDash CMS on Astro 6 / Cloudflare Workers with `@emdash-cms/plugin-embeds`. Blog post bodies are stored as Portable Text — a structured JSON array where each block has a `_type` and `_key`.
{ "_type": "youtube", "_key": "abc123", "id": "https://youtube.com/watch?v=..." } The editor is TipTap (ProseMirror-based), and EmDash translates between ProseMirror nodes and Portable Text using `portableTextToProsemirror` on load and `prosemirrorToPortableText` on save.
What Was Actually Happening
The failure involved four cooperating bugs that formed a self-healing-erasure loop.
Bug 1 — The Modal Always Inserts, Even Empty
const handleSubmit = (e) => {
e.preventDefault();
if (block?.fields && block.fields.length > 0) onInsert(formValues);
else {
const url = typeof formValues.id === "string" ? formValues.id.trim() : "";
if (url) onInsert({ id: url });
}
}; For field-based blocks like YouTube, `onInsert(formValues)` ran unconditionally. Pressing Enter on a blank modal inserted a YouTube block with `id: undefined`, which became `id: ""` in the saved Portable Text.
{ "_type": "youtube", "_key": "abc123", "id": "" } Bug 2 — The Converter Drops Blocks With Empty IDs
default: {
const { _type, _key, id, url, ...rest } = block;
const data = Object.fromEntries(
Object.entries(rest).filter(([k]) => !k.startsWith("_"))
);
const hasFieldData = Object.keys(data).length > 0;
if (id || url || hasFieldData) return {
type: "pluginBlock",
attrs: { blockType: _type, id: id || url || "", data }
};
return {
type: "paragraph",
content: [{
type: "text",
text: `[Unknown block type: ${block._type}]`,
marks: [{ type: "code" }]
}]
};
} Because `id`, `url`, and `hasFieldData` were all falsy for the empty YouTube block, the converter silently downgraded it into a paragraph containing `[Unknown block type: youtube]`.
Bug 3 — The Editor Overwrites Form State
React.useEffect(() => {
if (item) {
setFormData(item.data);
}
}, [item?.updatedAt, itemDataString]); After the page refetched the saved item, the corrupted body replaced the editor’s in-memory form state.
Bug 4 — Autosave Seals the Fate
EmDash autosaved two seconds later, permanently overwriting the original embed block with the corrupted paragraph node.
The Full Loop
User types /youtube, presses Enter on blank modal
↓
Bug 1: handleSubmit calls onInsert({}) unconditionally
↓
Editor shows YouTube widget with id=""
↓
User saves → { _type: "youtube", id: "" } stored
↓
Bug 2: convertPTBlock converts it into [Unknown block type: youtube]
↓
Bug 3: useEffect overwrites formData with corrupted content
↓
Bug 4: Autosave persists corrupted content
↓
YouTube embed is gone forever The Fix
Fix 1 — Always Preserve Plugin Blocks
default: {
const { _type, _key, id, url, ...rest } = block;
const data = Object.fromEntries(
Object.entries(rest).filter(([k]) => !k.startsWith("_"))
);
return {
type: "pluginBlock",
attrs: { blockType: _type, id: id || url || "", data }
};
} Removing the falsy guard ensures plugin blocks always round-trip faithfully, even when fields are empty.
Fix 2 — Guard the Modal Submit
if (block?.fields && block.fields.length > 0) {
if (hasPluginBlockFormData(formValues)) onInsert(formValues);
} This mirrors the existing validation logic and prevents blank YouTube blocks from being inserted at all.
Why Both Fixes Matter
Fix 2 prevents invalid blocks from being created, but Fix 1 is still necessary because previously-saved empty blocks — or any future malformed plugin blocks — must survive serialization without being destroyed.
Patching a Third-Party Package
The fix lives in `patches/@emdash-cms+admin+0.10.0.patch` via `patch-package`. Because `patch-package` 8.0.1 doesn’t auto-detect `pnpm-lock.yaml`, the patch hunks had to be constructed manually using `diff -u`.
rm -rf node_modules/.vite dist
pnpm dev:ui Clearing `node_modules/.vite` is required because the admin bundle is cached inside Vite’s SSR optimizer output. Without clearing the cache, the worker continues serving the stale patched bundle.