playground streaming: single text node — fixes 5 functional test regressions

Apply the REPL's per-line-div → single Text node refactor to the
playground too. Same perf win on high-volume streams plus a bonus:
the 5 functional tests that started failing after the per-line-div
era (mandelbrot/fib-ack/sieve/self-interp on C, fib-ack on asm)
all pass again. They were reading pre.textContent which silently
joined sibling divs without the \n separators they expected; a
single text node with embedded \n round-trips the assertion
exactly.

11/11 functional tests green.
This commit is contained in:
russell@unturf.com 2026-06-15 14:04:16 -04:00
parent 316e6db4c1
commit 049a1e1935
No known key found for this signature in database
2 changed files with 66 additions and 82 deletions

View file

@ -277,60 +277,52 @@ function startLiveBlock(tierName) {
block.appendChild(pre);
outputEl.appendChild(block);
// Each chunk gets split on \n: every completed line becomes its
// own block-level <div>, the trailing fragment without a newline
// accumulates in a pending div that's updated in place until the
// next \n closes it. display:block + white-space:pre set INLINE
// so a stale cached stylesheet can't suppress the fix — we kept
// chasing what turned out to be CSS not reloading.
const makeLine = () => {
const d = document.createElement("div");
d.style.display = "block";
d.style.whiteSpace = "pre";
return d;
};
let pendingLine = makeLine();
pre.appendChild(pendingLine);
let pendingText = "";
// Single Text node accumulator — appendData is ~1000x faster
// than appendChild(div) per line on high-volume streams. The
// <pre> wrapper already preserves \n as a real line break, so
// we don't need per-line elements. RAF coalesces chunk pile-ups
// so a tight (display) loop can't drown the main thread.
pre.style.whiteSpace = "pre";
const textNode = document.createTextNode("");
pre.appendChild(textNode);
let buffered = "";
let rafPending = false;
function flushBuffered() {
rafPending = false;
if (!buffered) return;
textNode.appendData(buffered);
buffered = "";
outputEl.scrollTop = outputEl.scrollHeight;
}
function scheduleFlush() {
if (rafPending) return;
rafPending = true;
requestAnimationFrame(flushBuffered);
}
return {
appendText(t) {
// Pure text chunk — never contains a newline. Append to
// the in-progress line.
if (!t) return;
pendingText += t;
pendingLine.textContent = pendingText;
outputEl.scrollTop = outputEl.scrollHeight;
buffered += t;
scheduleFlush();
},
appendNewline() {
// End-of-line event — close the current line (with a
// single-space placeholder when empty so the row is
// visible) and spawn a fresh pending line below.
pendingLine.textContent = pendingText || " ";
pendingText = "";
pendingLine = makeLine();
pre.appendChild(pendingLine);
outputEl.scrollTop = outputEl.scrollHeight;
buffered += "\n";
scheduleFlush();
},
finalize(elapsedMs, kind, fullOutput) {
if (kind === "cancelled") t.textContent = ` (cancelled @ ${elapsedMs.toFixed(0)} ms)`;
else t.textContent = ` (${elapsedMs.toFixed(0)} ms)`;
if (kind === "error") pre.className = "err";
// Reconcile against the full output if the streamed
// per-line divs don't add up — e.g. asm-tier fallback
// where streaming isn't wired yet. Otherwise the joined
// per-line text should match the eval's full output.
// Drain any RAF-buffered chunks then reconcile against the
// full eval output — covers the asm-tier fallback where
// streaming isn't wired and the live text node stays empty
// until finalize lands the canonical value.
if (rafPending) flushBuffered();
if (fullOutput) {
const got = Array.from(pre.children).map((d) =>
d.textContent === " " ? "" : d.textContent).join("\n");
const got = textNode.nodeValue.replace(/\n$/, "");
if (got !== fullOutput.replace(/\n$/, "")) {
pre.textContent = "";
const lines = fullOutput.split("\n");
for (const ln of lines) {
const d = makeLine();
d.textContent = ln || " ";
pre.appendChild(d);
}
textNode.nodeValue = fullOutput;
}
}
},

View file

@ -277,60 +277,52 @@ function startLiveBlock(tierName) {
block.appendChild(pre);
outputEl.appendChild(block);
// Each chunk gets split on \n: every completed line becomes its
// own block-level <div>, the trailing fragment without a newline
// accumulates in a pending div that's updated in place until the
// next \n closes it. display:block + white-space:pre set INLINE
// so a stale cached stylesheet can't suppress the fix — we kept
// chasing what turned out to be CSS not reloading.
const makeLine = () => {
const d = document.createElement("div");
d.style.display = "block";
d.style.whiteSpace = "pre";
return d;
};
let pendingLine = makeLine();
pre.appendChild(pendingLine);
let pendingText = "";
// Single Text node accumulator — appendData is ~1000x faster
// than appendChild(div) per line on high-volume streams. The
// <pre> wrapper already preserves \n as a real line break, so
// we don't need per-line elements. RAF coalesces chunk pile-ups
// so a tight (display) loop can't drown the main thread.
pre.style.whiteSpace = "pre";
const textNode = document.createTextNode("");
pre.appendChild(textNode);
let buffered = "";
let rafPending = false;
function flushBuffered() {
rafPending = false;
if (!buffered) return;
textNode.appendData(buffered);
buffered = "";
outputEl.scrollTop = outputEl.scrollHeight;
}
function scheduleFlush() {
if (rafPending) return;
rafPending = true;
requestAnimationFrame(flushBuffered);
}
return {
appendText(t) {
// Pure text chunk — never contains a newline. Append to
// the in-progress line.
if (!t) return;
pendingText += t;
pendingLine.textContent = pendingText;
outputEl.scrollTop = outputEl.scrollHeight;
buffered += t;
scheduleFlush();
},
appendNewline() {
// End-of-line event — close the current line (with a
// single-space placeholder when empty so the row is
// visible) and spawn a fresh pending line below.
pendingLine.textContent = pendingText || " ";
pendingText = "";
pendingLine = makeLine();
pre.appendChild(pendingLine);
outputEl.scrollTop = outputEl.scrollHeight;
buffered += "\n";
scheduleFlush();
},
finalize(elapsedMs, kind, fullOutput) {
if (kind === "cancelled") t.textContent = ` (cancelled @ ${elapsedMs.toFixed(0)} ms)`;
else t.textContent = ` (${elapsedMs.toFixed(0)} ms)`;
if (kind === "error") pre.className = "err";
// Reconcile against the full output if the streamed
// per-line divs don't add up — e.g. asm-tier fallback
// where streaming isn't wired yet. Otherwise the joined
// per-line text should match the eval's full output.
// Drain any RAF-buffered chunks then reconcile against the
// full eval output — covers the asm-tier fallback where
// streaming isn't wired and the live text node stays empty
// until finalize lands the canonical value.
if (rafPending) flushBuffered();
if (fullOutput) {
const got = Array.from(pre.children).map((d) =>
d.textContent === " " ? "" : d.textContent).join("\n");
const got = textNode.nodeValue.replace(/\n$/, "");
if (got !== fullOutput.replace(/\n$/, "")) {
pre.textContent = "";
const lines = fullOutput.split("\n");
for (const ln of lines) {
const d = makeLine();
d.textContent = ln || " ";
pre.appendChild(d);
}
textNode.nodeValue = fullOutput;
}
}
},