DEV Community

Cover image for CodeMirror Showed My HTML. The Published Post Was Empty.
John
John

Posted on Originally published at hexisteme.github.io

CodeMirror Showed My HTML. The Published Post Was Empty.

Originally published on hexisteme notes.

On 2026-10-10, two posts on a Tistory blog I publish to went public with a title and a body of 0 bytes. The script that prepared them had checked its own work, and the check had passed for both.

Some context. Tistory asks for a human captcha on every save, so my publisher's own POST and PUT requests were coming back 400. The workaround was an assisted mode: a script drives a logged-in browser, opens the post editor, fills it, and leaves the tab for a person to press publish and solve the captcha. The script's whole job ends at one claim: this tab now holds the right post. That claim was false twice, and nothing in the script noticed.

The check that could not fail

The post editor has an HTML mode backed by a CodeMirror instance. The fill step put the prepared HTML in with setValue(). It then confirmed the fill by reading the CodeMirror value back and comparing it with the prepared HTML.

They matched, because the value it read was the value it had just written. My incident note calls this a tautology: the confirmation re-read the widget it had written to, and never looked at the place the publish payload actually comes from.

Where the payload actually comes from

The editor is a React app with a Redux store. When the post is published, the body in the payload comes from post.newPost.content in that store, not from the CodeMirror instance on screen. Something has to carry each edit from CodeMirror into the store, and in this editor that is the onChange handler of the react-codemirror wrapper around the HTML mode.

That handler ignores changes whose origin is setValue. So the sequence on the day was:

  • setValue() put the HTML into CodeMirror.
  • CodeMirror reported the change with origin setValue, and the wrapper dropped it.
  • The store's content stayed empty.
  • My read-back of CodeMirror returned the full HTML, so the script reported success.
  • The post was published, and the payload carried an empty body.

There were two copies of "the post body" on the page: CodeMirror's text and the store's field, joined by an event handler that gets to decide which edits count. My check read the copy the submit path never reads. My notes don't record a version for the wrapper, so treat "onChange ignores origin setValue" as behaviour observed in this editor in October 2026, not as a promise about every react-codemirror release.

The repair: write like an edit, read where the payload reads

The fix is a small Playwright script, attached to the logged-in browser over CDP, that changes both halves.

Fill through an edit the wrapper forwards. Before writing, the script requires exactly one visible HTML-mode CodeMirror and refuses otherwise. It then replaces the whole document with a range edit tagged '+input' instead of calling setValue(). This is the line from the script:

cm.replaceRange(html, {line: 0, ch: 0}, {line: last, ch: cm.getLine(last).length}, '+input');
Enter fullscreen mode Exit fullscreen mode

Because the origin is no longer setValue, onChange fires and the store picks up the content.

Verify at the store. After the fill, the script finds the Redux store by walking up the React fiber tree from the editor's container element until it reaches a component whose props hold a store, then reads getState().post.newPost. Trimmed from the script:

const key = Object.keys(el).find(k => k.startsWith('__reactFiber$'));
for (let f = el[key]; f; f = f.return) {
  const store = f.memoizedProps && f.memoizedProps.store;
  if (store && store.getState) return store.getState().post.newPost;
}
Enter fullscreen mode Exit fullscreen mode

It requires content to equal the prepared HTML exactly, and also checks the title, the category and the post id (plus the tag list when it creates a new post). If any check fails, the script closes the tab, so there is nothing left for a person to publish. Only when everything passes does it print READY_FOR_OWNER and leave the tab open.

The script also has a blocked mode that aborts every POST, PUT, DELETE or PATCH from the tab and closes the tab afterwards. Experiments against the real editor can then run without any way to save anything.

Validation

The two empty posts were refilled in place through the new path. Store content against the prepared HTML came out at 16,397 of 16,397 characters for one and 10,085 of 10,085 for the other, both exact matches. The owner then published both through the captcha.

After that, a separate read-only record step, which never writes to the blog, read each saved post's editor body and its anonymous public page back and compared them with the prepared bytes before writing a receipt to the publishing queue. Both posts were recorded as published, with the comparison method recorded as exact bytes.

Two days later, two new posts went through the same path. Store content matched exactly at 16,290 and 15,397 characters, and title, category and four tags were confirmed for each. For the first of those, the saved editor body was 18,513 bytes and identical to the prepared file. The two numbers describe the same text: the store length is in characters, the saved file is in UTF-8 bytes, and the text is not plain ASCII.

The general version

A read-back from the widget you just wrote to is an echo. It tells you the write call didn't throw. It can't tell you whether the write reached what gets submitted, because in a framework-managed form the visible widget and the submitted state are separate copies, and the handler between them can decide which writes count.

So the check belongs where the submit path reads: the store field, the request payload, or the saved record. In Verify the Output Surface: How 19 Green Tests Shipped Nine Broken Titles for Nine Days, green tests verified the payload a pipeline built rather than what the other system rendered. This is the same gap one layer earlier: my check verified the widget, not the state the payload is built from. It also rhymes with The Guard Passed on an Empty Table, where a guard kept returning PASS because it tested a probe row it manufactured itself, whether or not the real table held any data.

Aside: bootout is not off

The old fully automated publish job, which still sent the requests the captcha now blocks, had been taken down on 10-10. Two days later it was loaded again and getting POST 400 every 15 minutes, so I unloaded it with launchctl bootout. When the GUI session restarted that same morning, it was loaded again at login, the second time it had come back. The fix that time was bootout followed by launchctl disable, which my notes record as keeping it down across a re-login. In this setup, unloading a job was not the same as keeping it off.

What this does not prove

  • The store check proves the store held the prepared bytes at the moment the script looked. Between that moment and the publish click, the tab is in a person's hands.
  • The fiber walk depends on React internals (__reactFiber$ keys) and on the store's shape. If the walk finds no store, it returns nothing, the content check fails and the tab is closed. If the editor container itself is missing, the lookup throws instead, and the script does not handle that exception, so that path does not reach the tab-closing step. Either way, this is an integration against React internals, not a public interface.
  • The record step only accepts a post that is already public. It can refuse to write a receipt; it cannot take an empty post back down.
  • A correct fill doesn't make the bookkeeping correct. One of the later posts went out with exact bytes, but the queue refused to record it because the worklist had moved to a newer revision in the meantime. The guard behaved as designed and I didn't bypass it, so the queue has no receipt for that post.

Email list for these notes: hexisteme.beehiiv.com — no issue has gone out yet, so you would be on it before the first one. No welcome sequence, no course, no upsell.

More notes at hexisteme.github.io/notes.

Top comments (0)