From 049a1e193541eba65144d7f0136350b789b882c9 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Mon, 15 Jun 2026 14:04:16 -0400 Subject: [PATCH] =?UTF-8?q?playground=20streaming:=20single=20text=20node?= =?UTF-8?q?=20=E2=80=94=20fixes=205=20functional=20test=20regressions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- wasm/app/app.js | 74 +++++++++++++++++++------------------------ www/playground/app.js | 74 +++++++++++++++++++------------------------ 2 files changed, 66 insertions(+), 82 deletions(-) diff --git a/wasm/app/app.js b/wasm/app/app.js index c7a27de..f11fe80 100644 --- a/wasm/app/app.js +++ b/wasm/app/app.js @@ -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
, 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 + //
 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;
                 }
             }
         },
diff --git a/www/playground/app.js b/www/playground/app.js
index c7a27de..f11fe80 100644
--- a/www/playground/app.js
+++ b/www/playground/app.js
@@ -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 
, 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 + //
 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;
                 }
             }
         },