CodeMirror Showed My HTML. The Published Post Was Empty.

← hexisteme · notes · 2026-10-13

A browser-automation script filled a blog editor's HTML-mode CodeMirror with setValue() and confirmed the fill by reading the same CodeMirror value back, and two posts went public with a title and a 0-byte body. The editor's react-codemirror onChange ignored changes with origin setValue, so the Redux field the publish payload comes from, post.newPost.content, stayed empty. The repair fills through replaceRange(..., '+input') and verifies the store itself, found through the React fiber tree, with an exact match before a person may publish.

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:

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');

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;
}

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

FAQ

Why did the fill check pass when the published body was empty?

It read back the CodeMirror value the script had just written with setValue(), so it matched by construction. The publish payload's body comes from the Redux field post.newPost.content, and the editor's react-codemirror onChange ignores changes whose origin is setValue, so that field stayed empty.

How was the editor fill changed?

The script first requires exactly one visible HTML-mode CodeMirror, then replaces the whole document with replaceRange and the origin '+input' instead of calling setValue(). With an origin other than setValue, onChange fires and the store picks up the content.

How does the script verify what will actually be published?

It walks up the React fiber tree from the editor's container element to a component whose props hold the Redux store, reads getState().post.newPost, and requires content to equal the prepared HTML exactly, along with the title, category and post id, plus tags for a new post. If any check fails it closes the tab, so there is nothing to publish.

What did the repaired path show on real posts?

The two empty posts were refilled in place with exact store matches of 16,397 of 16,397 and 10,085 of 10,085 characters, then published by the owner through the captcha. A read-only record step compared the saved editor body and the public page with the prepared bytes and recorded both as published. Two new posts later matched exactly at 16,290 and 15,397 characters.

What does the store check not prove?

It proves the store held the prepared bytes when the script looked, not what happens between that moment and the publish click. It relies on React internals and the store's shape, and the post-publish record step can refuse a receipt but cannot take an already public post down.

Related notes

Fixed your problem? Good — that's the whole point of this page. Every note on this site is free to read. I write one of these up whenever I hit a failure worth recording: agent-fleet operations, harness bugs, and the measurement that proved the fix. The email list for these notes — no issue has gone out yet, so you would be on it before the first one.
← hexisteme · notes · about · editorial policy · privacy · contact · CC-BY 4.0