A bug report arrived that sounded like nonsense. The code was fixed, the output was right, and the console was still showing the error from before — sitting above the correct answer.
The console was working perfectly. It was printing a message from a page that no longer existed.
What auto-run actually does
With auto-run on, the preview rebuilds after almost every keystroke. Each rebuild clears the console, assembles a fresh HTML document and hands it to the preview frame:
consoleOutput.innerHTML = "";
iframe.srcdoc = html;
Two lines, and both of them look like they finish before the next one starts. Only one of them does.
Assigning srcdoc is not a delete
Clearing the console is immediate. Assigning srcdoc is
not. The browser schedules a navigation; the old document stays alive
until that navigation is processed, and while it is alive it can
still run. A pending promise resolves. A setTimeout
fires. A script that was still parsing finishes parsing and throws.
So the sequence that produced the bug looks like this:
- You type something broken. Run one starts loading.
- You fix it. Run two starts, and clears the console.
- Run one finally executes and throws. It writes into the console that run two just cleared.
- Run two finishes and logs its correct output.
Errors are prepended to the top of the console and ordinary output is appended to the bottom. So the message you get is a stale error pinned to the top with the right answer underneath it. It looks exactly like a broken console, which is why it was reported as one.
Why "clear it again" does not work
The obvious fix is to clear the console once the new document has loaded instead of before. That trades one bug for a worse one: any output the new page produces while it is still loading gets wiped along with the old.
Debouncing does not help either. The window between the two runs can be arbitrarily long, because it depends on when the old document's timers happen to fire. There is no delay you can pick that is reliably long enough and not annoying.
Give each run a ticket
The fix that works is to stop trying to time it and start labelling it. Every document gets a number when it is created, and it carries that number for its whole life. Before writing anything to the console it asks the parent one question:
window.__codxIsCurrentPreviewRun = (runId) =>
String(runId) === String(previewRunId);
If a newer document has taken over, the answer is no and the message is dropped. The old page can throw as much as it likes; nobody is listening any more.
The detail that nearly broke it
The first version incremented the run number at the top of the rebuild, next to the console clear. That was wrong.
The preview skips reloading when the generated HTML has not changed,
which is what stops the page flashing white on every keystroke. If
the number moves on every rebuild, then on a skipped rebuild the
document still on screen is left holding a number nobody recognises.
A page running setInterval would go silent for no
visible reason.
So the number only moves when a document is actually written:
previewRunId += 1;
iframe.srcdoc = html.replace("__CODX_RUN_ID__", String(previewRunId));
The placeholder is substituted after the comparison, not before, so the stored HTML stays byte-identical between runs and the skip keeps working.
The general shape
This is not really about iframes. It is about any system where you replace a running thing with a new one and assume the old one stops at the moment you asked it to.
Cancelling a fetch, tearing down a worker, unmounting a component that has a request in flight: same shape, same fix. Do not try to work out when the old one dies. Label the work, and ignore results that belong to a generation you have moved on from.
The clue that you have this bug is always the same. Something correct is on screen, and something stale is on screen with it, and the stale thing is on top.