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:
parent
e272b6bfaa
commit
316e6db4c1
3 changed files with 96 additions and 150 deletions
|
|
@ -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`;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue