Series seed

Chromium passed every time

The first end-to-end spec found two defects, and one of them only one browser could see.

6 min read

The last thing on the 20th was a test.

Not a unit test. A Playwright spec that starts the real dev server and drives the real editor in a real browser: create a post, type into it, insert a card through the slash menu, wait for the status to say Saved, then read the file off disk and check what’s in it. Then reopen the post and check the card survived.

Everything at that point was green. The unit suite, the build, the scope check, the round trip over every post on the site. I wrote the spec expecting it to confirm all that.

It took five review rounds to get it passing, and every one of them came from running it.

The one nobody could have seen by looking

The first defect it found:

[tiptap error]: Invalid JSON content

A newly created post came back from the parser as content: [], an empty document, and Tiptap refuses an empty document. So a brand new post could not be edited. At all. Not a rough edge, not a layout problem. The main path of the entire tool, make a new post and write in it, did not work.

The fix is a ternary:

content: tree.children.length > 0
  ? tree.children.map((node) => toEditorNode(node, source))
  : [{ type: 'paragraph' }],

An empty file has no children, so give it one empty paragraph to stand in.

What I keep coming back to is not the bug, it’s why it survived so long. From the runlog, and I’m quoting myself here because I want the sentence to stay embarrassing:

“Create new post” was broken and every earlier check had missed it, because manual review always opened existing posts.

Of course it did. I had forty posts sitting there. Every time I opened the editor to look at something I clicked one of them, because that’s where the content was. The empty case is the one case I never clicked, and it’s the case every reader of this post would hit on their first try.

There’s an echo here I didn’t notice at the time. Later the same day, the empty wrapper card bug turned out to be exactly the same shape, content: [] handed to something that requires at least one child, just one level further down. Same mistake, twice, in one day, found by two different tests. Neither found by me reading the code.

The one that could eat your work

The second defect is the one I think about.

Astro broadcasts a full-reload to the browser whenever content changes. The editor writes content. So every autosave told the page to throw itself away and start again, which is a fairly comprehensive way to lose the paragraph you’re in the middle of.

I knew about this. It’s the thing I probed on the 19th before writing the plan, and the probe said it was fine, and the interception went in with the canvas on the morning of the 20th. The commit body from that morning even says the editor ignores content-driven full reloads.

It did ignore them. Just not always.

The interception was registered when the editor island hydrated. The reload can arrive before that. It’s a race, and the window is small, and here is the part that matters:

Chromium passed every time.

Webkit lost the race, repeatedly and visibly. From the notes: navsTotal=2, document empty, status Idle, puts=0. Two navigations where there should be one, nothing in the document, nothing written. Whatever had been typed was gone.

If I’d run one browser, and chromium is the one anybody reaches for first, this would have shipped. It would have sat there being fine on my machine and occasionally, on a slow load, eating a paragraph. That’s the category of bug you never quite pin down, because by the time you notice, the evidence is what’s missing.

It took three attempts to actually fix.

First a parse-time script, which still raced, because it was racing on import('/@vite/client') itself. Then suppressing the reload server-side, which missed Astro’s later data-store flush and let one through anyway. What finally worked is uglier than both, and it’s the right answer:

const Native = window.WebSocket;
const Patched = function (...args) {
  const socket = new Native(...args);
  socket.addEventListener('message', (e) => {
    if (typeof e.data === 'string' && e.data.includes('"type":"full-reload"')) e.stopImmediatePropagation();
  }, true);
  return socket;
};
window.WebSocket = Patched;

Wrap WebSocket itself, inline, before Vite’s client has a chance to connect, and drop full-reload frames on the floor synchronously. Not “ignore the reload when it happens”, but “be there before the thing that would deliver it exists”. The measured result after that: marker: alive, text: "hello there", status: "Saved", one navigation, in both browsers.

I don’t love patching a global. It only runs in the editor route, which only exists in dev, which is the whole argument from part three paying rent. Anywhere else I’d think much harder about it.

Two smaller things, for honesty

The spec itself was wrong twice, and both times it was my fault rather than the implementation’s.

The exact test I’d written into the plan expected the saved file to end with a trailing newline. The serializer didn’t add one. Codex stopped rather than adjusting the test to match the code, which was right, because in this case the test was correct and the serializer was wrong. Files should end with exactly one newline. That’s the serializer’s job, and now it does it.

And a later round stopped because my own block was eleven lines, against a rule in my own AGENTS.md that says inline scripts stay at ten or under. I wrote the rule. I broke the rule. The executor refused to break it for me, which is a slightly humbling feature to have built.

Where it landed

4 passed (9.4s)

Two tests, two browsers, nine seconds. It looks like nothing. Behind it is the reason I now believe the review half of the loop is not optional: a full green suite, a clean build, a clean scope check, and creating a post was broken the entire time.

The thing that found it wasn’t cleverness. It was doing, once, in a browser, the most obvious thing a person would do with the tool.

That was the 20th. There are no commits at all on the 21st or the 22nd, which I’ll let you draw your own conclusions about.

When it picks up again on the 23rd, it’s to put a lock on a door that, by my own argument two parts ago, nobody could reach.

seed maturity Seed — this note will change as the strip does