Editor flush adds three blank lines above the frontmatter when an async command runs from the command palette

What I was trying to do

When I copy a range of cells in Excel and paste it into a note, Obsidian gives me a Markdown table. That is usually what I want. But I write reports where the picture is the content — conditional formatting, merged headers, colour scales — and a Markdown table throws all of that away.

The picture is already on the clipboard: copying cells in Excel puts text/plain, text/html, text/rtf and image/png there at the same time. Nothing is missing; Obsidian simply picks the HTML. So I wrote a small command for my own vault that picks the image instead:

  1. read the image with clipboard.readImage();
  2. if it has an alpha channel, composite it onto white — a transparent background renders as invisible black text in dark mode;
  3. save the attachment with getAvailablePathForAttachment() + vault.createBinary();
  4. insert the embed at the cursor.

Steps 2 and 3 are asynchronous, so the command awaits before it writes anything to the document. That turns out to matter.

What goes wrong

Run the command from the command palette, in a note with frontmatter, in Live Preview: three blank lines appear above the frontmatter fence, which breaks the note’s properties. The embed also lands a few characters off, on the wrong line.

Those blank lines are not written by my plugin. I wrapped cm.dispatch and logged the stack of every transaction that changed the document:

108 -> 111 chars   top before: "---\ntitle:"   top after: "\n\n\n---\ntitle"

Fr <- Or <- e.flush <- e.onSelectionChange <- <my async command>

While the command is suspended on its first await, the palette finishes closing and hands focus back to the editor. That selectionchange makes CodeMirror re-sync with the DOM, and the flush inserts \n\n\n at offset 0.

Two details that took a long time to find, and that may point at the cause:

  • the flush runs synchronously from inside a sync Electron IPC call — clipboard.readImage() pumps the message loop, which lets the pending selectionchange run in the middle of my function, before I have even looked at the document;
  • it is applied together with the next transaction, so it lands inside my own editor.replaceRange(...) call, immediately before my edit is applied.

The same command never corrupts anything when it is bound to a hotkey, or when I invoke it with app.commands.executeCommandById(...). No modal closing, no flush — which is why every test I ran programmatically passed while the real usage kept failing.

What I do today

I could not find a supported way to let the editor settle before measuring, so the command now:

  1. snapshots the document and the selection synchronously inside the editorCallback, before any await — including before readImage(), since that is what pumps the loop;
  2. fires an empty transaction (replaceRange("", {line:0,ch:0}, {line:0,ch:0})) to materialise the pending flush harmlessly;
  3. compares the leading newlines against the snapshot and deletes whatever appeared, both before and after inserting;
  4. computes the insertion offset inside the snapshot, never with posToOffset() on the live state.

It works, and I can reproduce the failure on demand by injecting "\n\n\n" at offset 0 during the awaits. But writing content and then deleting it to undo something the editor did on its own is obviously the wrong shape for a fix.

What I am asking

Is there a supported way to handle this? Something that flushes the DOM observer on demand, or suppresses the re-sync while a command holds the editor, or — simplest of all — a guarantee that the frontmatter fence is never pushed down by a focus-driven flush.

And if plugins are not expected to await inside an editorCallback at all, it would help to say so. Right now the failure is silent and it corrupts the user’s notes.

I hit the same class of bug in my text-expansion plugin (Snipsy): an async clipboard read sits between “decide where to write” and “write”, and anything that moves the document during that gap sends the edit to the wrong place. Your snapshot-before-await instinct is right. Two things let me drop the “delete whatever appeared” step:

Map the position instead of storing an offset. Register one update listener that carries every pending anchor through each transaction:

const anchors = new Set<{ pos: number }>();

this.registerEditorExtension(
  EditorView.updateListener.of((u) => {
    if (!u.docChanged) return;
    for (const a of anchors) a.pos = u.changes.mapPos(a.pos);
  })
);

// in the command, before any await:
const anchor = { pos: editor.cm.state.selection.main.head };
anchors.add(anchor);
try {
  // ...await the image read and createBinary()...
  editor.cm.dispatch({ changes: { from: anchor.pos, insert: embed } });
} finally {
  anchors.delete(anchor);
}

When the flush inserts \n\n\n at offset 0, your anchor shifts by 3 and the embed lands where the cursor was.

Check the surroundings before writing. Keep the few characters around the anchor from your snapshot and compare them with the live document right before the dispatch. If they differ, skip the edit and show a Notice. A missing embed is better than one that ends up in the wrong spot.

I’d also try the async navigator.clipboard.read() (it gives you a ClipboardItem with image/png) in place of the sync clipboard.readImage(). That way nothing pumps the message loop in the middle of your function.

None of this fixes the three blank lines themselves, though. A focus-driven flush that pushes the frontmatter down is a core bug. Your repro (async editorCallback + command palette + note with frontmatter) is minimal enough for Bug reports.