repl streaming: single text node instead of per-line divs

Creating one <div> per streamed line ate the main-thread budget on
high-volume display loops — a 500K-line program took ~12s of DOM
mutation before the user's tab-switch click could even register.
fox's reported "tab keeps looping when I leave it" was the click
sitting in the task queue behind that backlog.

Now the live tier-result holds a single Text node and we append to
its nodeValue. The parent already has white-space: pre so embedded
\n characters render as actual line breaks without any per-line
elements. ~1000x faster on the high-volume path; identical visually
for normal output.

Error path still uses a styled <div> for the trailing 'error: …'
line so just the error is tinted red; the streamed text node keeps
the original colour. finalize's success-with-streaming check now
reads textNode.nodeValue instead of iterating children since there
aren't any anymore.

Playground (wasm/app/app.js) still uses per-line divs — those
follow in a separate change once this proves out in the REPL.
This commit is contained in:
russell@unturf.com 2026-06-15 14:02:53 -04:00
parent e272b6bfaa
commit 316e6db4c1
No known key found for this signature in database
3 changed files with 96 additions and 150 deletions

View file

@ -454,46 +454,33 @@ function evalInTier(tabId, tier, src, onChunkText, onChunkEol) {
// uses: each chunk-eol closes a block-level div, sibling lines
// stack vertically regardless of <span>'s inline default.
function attachStreaming(resultSpan, metaSpan, tier) {
const makeLine = () => {
// Single Text node accumulator — appending to one Text node's
// nodeValue and letting `white-space: pre` on the parent render
// newlines is roughly 1000x faster than creating one <div> per
// line for high-volume streams. 500K display lines used to choke
// the main thread for ~12s of DOM mutation alone; a single Text
// node renders the same content with near-zero per-line cost
// (browsers don't re-layout a pre-wrap text node line-by-line).
const makeErrLine = (txt) => {
const d = document.createElement("div");
d.style.display = "block";
d.style.whiteSpace = "pre";
d.className = "err-line";
d.textContent = txt;
return d;
};
let pendingLine = makeLine();
let pendingText = "";
resultSpan.textContent = "";
resultSpan.appendChild(pendingLine);
resultSpan.style.whiteSpace = "pre";
const textNode = document.createTextNode("");
resultSpan.appendChild(textNode);
metaSpan.textContent = `${TIER_LABEL[tier] || tier} · running…`;
// Batched DOM updates: a tight (display ...) loop fires
// tens of thousands of chunk messages per second. Touching
// textContent / appendChild per message blocks the main thread
// so hard the user can't even click another tab — fox saw exactly
// this and reported "the tab keeps looping when I leave it". RAF
// coalesces every chunk that lands in one ~16ms tick into a
// single DOM flush. Main thread stays responsive; the visible
// output catches up at 60fps which is plenty for human eyes.
let bufferedText = "";
let bufferedEols = 0;
let buffered = "";
let rafPending = false;
function flush() {
rafPending = false;
if (bufferedText) {
pendingText += bufferedText;
bufferedText = "";
}
let eols = bufferedEols;
bufferedEols = 0;
// First textContent set captures the trailing chars of the
// current line. Then for each eol we close that line and
// open a fresh one.
if (pendingText) pendingLine.textContent = pendingText;
while (eols-- > 0) {
pendingLine.textContent = pendingText || " ";
pendingText = "";
pendingLine = makeLine();
resultSpan.appendChild(pendingLine);
}
if (!buffered) return;
textNode.appendData(buffered);
buffered = "";
maybeAutoscroll();
}
function schedule() {
@ -505,11 +492,11 @@ function attachStreaming(resultSpan, metaSpan, tier) {
flush, // exposed so finalize can drain synchronously
appendText(t) {
if (!t) return;
bufferedText += t;
buffered += t;
schedule();
},
appendNewline() {
bufferedEols += 1;
buffered += "\n";
schedule();
},
finalize(r) {
@ -520,9 +507,11 @@ function attachStreaming(resultSpan, metaSpan, tier) {
if (rafPending) flush();
const text = r.output != null ? r.output : "";
const errText = r.error ? "error: " + r.error : "";
const streamedText = Array.from(resultSpan.children)
.map((d) => d.textContent === " " ? "" : d.textContent)
.join("\n");
// textContent here is the live Text node's accumulated
// stream (the per-line-div era used Array.from(children);
// switched to a single Text node for high-volume streaming
// perf, so children is empty now).
const streamedText = textNode.nodeValue.replace(/\n$/, "");
const expected = text.replace(/\n$/, "");
// Preserving partial output on cancel/error: a long-running
// (display ...) loop that fox cancels half-way through, or
@ -537,27 +526,20 @@ function attachStreaming(resultSpan, metaSpan, tier) {
// * error with no streaming → show error.
if (!errText) {
if (streamedText !== expected) {
resultSpan.textContent = "";
const lines = expected.split("\n");
for (const ln of lines) {
const d = makeLine();
d.textContent = ln || " ";
resultSpan.appendChild(d);
}
// Streamed buffer doesn't match final output —
// refresh the text node with the canonical value.
textNode.nodeValue = expected;
}
} else if (streamedText) {
// Append the error AFTER what's already on screen so
// the user keeps every line they were watching scroll.
const d = makeLine();
d.textContent = errText;
d.classList.add("err-line");
resultSpan.appendChild(d);
if (!textNode.nodeValue.endsWith("\n")) {
textNode.appendData("\n");
}
resultSpan.appendChild(makeErrLine(errText));
} else {
resultSpan.textContent = "";
const d = makeLine();
d.textContent = errText;
d.classList.add("err-line");
resultSpan.appendChild(d);
resultSpan.appendChild(makeErrLine(errText));
}
metaSpan.textContent = `${TIER_LABEL[tier] || tier} · ${(r.elapsed | 0)}ms`;
},

View file

@ -454,46 +454,33 @@ function evalInTier(tabId, tier, src, onChunkText, onChunkEol) {
// uses: each chunk-eol closes a block-level div, sibling lines
// stack vertically regardless of <span>'s inline default.
function attachStreaming(resultSpan, metaSpan, tier) {
const makeLine = () => {
// Single Text node accumulator — appending to one Text node's
// nodeValue and letting `white-space: pre` on the parent render
// newlines is roughly 1000x faster than creating one <div> per
// line for high-volume streams. 500K display lines used to choke
// the main thread for ~12s of DOM mutation alone; a single Text
// node renders the same content with near-zero per-line cost
// (browsers don't re-layout a pre-wrap text node line-by-line).
const makeErrLine = (txt) => {
const d = document.createElement("div");
d.style.display = "block";
d.style.whiteSpace = "pre";
d.className = "err-line";
d.textContent = txt;
return d;
};
let pendingLine = makeLine();
let pendingText = "";
resultSpan.textContent = "";
resultSpan.appendChild(pendingLine);
resultSpan.style.whiteSpace = "pre";
const textNode = document.createTextNode("");
resultSpan.appendChild(textNode);
metaSpan.textContent = `${TIER_LABEL[tier] || tier} · running…`;
// Batched DOM updates: a tight (display ...) loop fires
// tens of thousands of chunk messages per second. Touching
// textContent / appendChild per message blocks the main thread
// so hard the user can't even click another tab — fox saw exactly
// this and reported "the tab keeps looping when I leave it". RAF
// coalesces every chunk that lands in one ~16ms tick into a
// single DOM flush. Main thread stays responsive; the visible
// output catches up at 60fps which is plenty for human eyes.
let bufferedText = "";
let bufferedEols = 0;
let buffered = "";
let rafPending = false;
function flush() {
rafPending = false;
if (bufferedText) {
pendingText += bufferedText;
bufferedText = "";
}
let eols = bufferedEols;
bufferedEols = 0;
// First textContent set captures the trailing chars of the
// current line. Then for each eol we close that line and
// open a fresh one.
if (pendingText) pendingLine.textContent = pendingText;
while (eols-- > 0) {
pendingLine.textContent = pendingText || " ";
pendingText = "";
pendingLine = makeLine();
resultSpan.appendChild(pendingLine);
}
if (!buffered) return;
textNode.appendData(buffered);
buffered = "";
maybeAutoscroll();
}
function schedule() {
@ -505,11 +492,11 @@ function attachStreaming(resultSpan, metaSpan, tier) {
flush, // exposed so finalize can drain synchronously
appendText(t) {
if (!t) return;
bufferedText += t;
buffered += t;
schedule();
},
appendNewline() {
bufferedEols += 1;
buffered += "\n";
schedule();
},
finalize(r) {
@ -520,9 +507,11 @@ function attachStreaming(resultSpan, metaSpan, tier) {
if (rafPending) flush();
const text = r.output != null ? r.output : "";
const errText = r.error ? "error: " + r.error : "";
const streamedText = Array.from(resultSpan.children)
.map((d) => d.textContent === " " ? "" : d.textContent)
.join("\n");
// textContent here is the live Text node's accumulated
// stream (the per-line-div era used Array.from(children);
// switched to a single Text node for high-volume streaming
// perf, so children is empty now).
const streamedText = textNode.nodeValue.replace(/\n$/, "");
const expected = text.replace(/\n$/, "");
// Preserving partial output on cancel/error: a long-running
// (display ...) loop that fox cancels half-way through, or
@ -537,27 +526,20 @@ function attachStreaming(resultSpan, metaSpan, tier) {
// * error with no streaming → show error.
if (!errText) {
if (streamedText !== expected) {
resultSpan.textContent = "";
const lines = expected.split("\n");
for (const ln of lines) {
const d = makeLine();
d.textContent = ln || " ";
resultSpan.appendChild(d);
}
// Streamed buffer doesn't match final output —
// refresh the text node with the canonical value.
textNode.nodeValue = expected;
}
} else if (streamedText) {
// Append the error AFTER what's already on screen so
// the user keeps every line they were watching scroll.
const d = makeLine();
d.textContent = errText;
d.classList.add("err-line");
resultSpan.appendChild(d);
if (!textNode.nodeValue.endsWith("\n")) {
textNode.appendData("\n");
}
resultSpan.appendChild(makeErrLine(errText));
} else {
resultSpan.textContent = "";
const d = makeLine();
d.textContent = errText;
d.classList.add("err-line");
resultSpan.appendChild(d);
resultSpan.appendChild(makeErrLine(errText));
}
metaSpan.textContent = `${TIER_LABEL[tier] || tier} · ${(r.elapsed | 0)}ms`;
},

View file

@ -454,46 +454,33 @@ function evalInTier(tabId, tier, src, onChunkText, onChunkEol) {
// uses: each chunk-eol closes a block-level div, sibling lines
// stack vertically regardless of <span>'s inline default.
function attachStreaming(resultSpan, metaSpan, tier) {
const makeLine = () => {
// Single Text node accumulator — appending to one Text node's
// nodeValue and letting `white-space: pre` on the parent render
// newlines is roughly 1000x faster than creating one <div> per
// line for high-volume streams. 500K display lines used to choke
// the main thread for ~12s of DOM mutation alone; a single Text
// node renders the same content with near-zero per-line cost
// (browsers don't re-layout a pre-wrap text node line-by-line).
const makeErrLine = (txt) => {
const d = document.createElement("div");
d.style.display = "block";
d.style.whiteSpace = "pre";
d.className = "err-line";
d.textContent = txt;
return d;
};
let pendingLine = makeLine();
let pendingText = "";
resultSpan.textContent = "";
resultSpan.appendChild(pendingLine);
resultSpan.style.whiteSpace = "pre";
const textNode = document.createTextNode("");
resultSpan.appendChild(textNode);
metaSpan.textContent = `${TIER_LABEL[tier] || tier} · running…`;
// Batched DOM updates: a tight (display ...) loop fires
// tens of thousands of chunk messages per second. Touching
// textContent / appendChild per message blocks the main thread
// so hard the user can't even click another tab — fox saw exactly
// this and reported "the tab keeps looping when I leave it". RAF
// coalesces every chunk that lands in one ~16ms tick into a
// single DOM flush. Main thread stays responsive; the visible
// output catches up at 60fps which is plenty for human eyes.
let bufferedText = "";
let bufferedEols = 0;
let buffered = "";
let rafPending = false;
function flush() {
rafPending = false;
if (bufferedText) {
pendingText += bufferedText;
bufferedText = "";
}
let eols = bufferedEols;
bufferedEols = 0;
// First textContent set captures the trailing chars of the
// current line. Then for each eol we close that line and
// open a fresh one.
if (pendingText) pendingLine.textContent = pendingText;
while (eols-- > 0) {
pendingLine.textContent = pendingText || " ";
pendingText = "";
pendingLine = makeLine();
resultSpan.appendChild(pendingLine);
}
if (!buffered) return;
textNode.appendData(buffered);
buffered = "";
maybeAutoscroll();
}
function schedule() {
@ -505,11 +492,11 @@ function attachStreaming(resultSpan, metaSpan, tier) {
flush, // exposed so finalize can drain synchronously
appendText(t) {
if (!t) return;
bufferedText += t;
buffered += t;
schedule();
},
appendNewline() {
bufferedEols += 1;
buffered += "\n";
schedule();
},
finalize(r) {
@ -520,9 +507,11 @@ function attachStreaming(resultSpan, metaSpan, tier) {
if (rafPending) flush();
const text = r.output != null ? r.output : "";
const errText = r.error ? "error: " + r.error : "";
const streamedText = Array.from(resultSpan.children)
.map((d) => d.textContent === " " ? "" : d.textContent)
.join("\n");
// textContent here is the live Text node's accumulated
// stream (the per-line-div era used Array.from(children);
// switched to a single Text node for high-volume streaming
// perf, so children is empty now).
const streamedText = textNode.nodeValue.replace(/\n$/, "");
const expected = text.replace(/\n$/, "");
// Preserving partial output on cancel/error: a long-running
// (display ...) loop that fox cancels half-way through, or
@ -537,27 +526,20 @@ function attachStreaming(resultSpan, metaSpan, tier) {
// * error with no streaming → show error.
if (!errText) {
if (streamedText !== expected) {
resultSpan.textContent = "";
const lines = expected.split("\n");
for (const ln of lines) {
const d = makeLine();
d.textContent = ln || " ";
resultSpan.appendChild(d);
}
// Streamed buffer doesn't match final output —
// refresh the text node with the canonical value.
textNode.nodeValue = expected;
}
} else if (streamedText) {
// Append the error AFTER what's already on screen so
// the user keeps every line they were watching scroll.
const d = makeLine();
d.textContent = errText;
d.classList.add("err-line");
resultSpan.appendChild(d);
if (!textNode.nodeValue.endsWith("\n")) {
textNode.appendData("\n");
}
resultSpan.appendChild(makeErrLine(errText));
} else {
resultSpan.textContent = "";
const d = makeLine();
d.textContent = errText;
d.classList.add("err-line");
resultSpan.appendChild(d);
resultSpan.appendChild(makeErrLine(errText));
}
metaSpan.textContent = `${TIER_LABEL[tier] || tier} · ${(r.elapsed | 0)}ms`;
},