From bc13b056ac0055e18ec553d811f61f77c05a1b3d Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Mon, 15 Jun 2026 06:19:04 -0400 Subject: [PATCH] playground streaming: per-line divs with inline display:block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reapplies the per-line-div approach lost in the autosave revert (c2d9eca) and pins display:block + white-space:pre as INLINE styles on each div instead of relying on the .stream-line CSS rule. We spent a long stretch chasing what looked like a deeper rendering bug — turned out the previous .stream-line class rule in style.css was loaded but the cached stylesheet bundle in fox's tab didn't include it, so each div kept its default inline display and the chunks still rendered horizontally even though the DOM had a clean sibling-div column. Each chunk now: walk byte-by-byte, accumulate text in a pending div, every \n closes the pending div with display:block already set and spawns a fresh sibling. Empty lines get a single space so they still take a row. finalize() reconciles against the full eval output (asm tier fallback) using the joined per-line text rather than comparing pre.textContent. --- wasm/app/app.js | 62 ++++++++++++++++++++++++++++++++++--------- www/playground/app.js | 62 ++++++++++++++++++++++++++++++++++--------- 2 files changed, 98 insertions(+), 26 deletions(-) diff --git a/wasm/app/app.js b/wasm/app/app.js index 1f9d71e..67a172e 100644 --- a/wasm/app/app.js +++ b/wasm/app/app.js @@ -261,27 +261,63 @@ function startLiveBlock(tierName) { pre.textContent = ""; 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 = ""; + return { append(chunk) { - // Append a TextNode rather than `pre.textContent += chunk` - // — the latter re-reads + restringifies + re-sets all - // existing children on every chunk, which under a fast - // C-tier loop (one chunk per (newline) printf) was - // dropping line breaks and rendering "tick 0tick 10000…" - // horizontally instead of stacking vertically. TextNode - // append preserves every byte verbatim. - pre.appendChild(document.createTextNode(chunk)); + if (!chunk) return; + let start = 0; + for (let i = 0; i < chunk.length; i++) { + if (chunk.charCodeAt(i) === 10) { + pendingText += chunk.slice(start, i); + pendingLine.textContent = pendingText || " "; + pendingText = ""; + pendingLine = makeLine(); + pre.appendChild(pendingLine); + start = i + 1; + } + } + if (start < chunk.length) { + pendingText += chunk.slice(start); + pendingLine.textContent = pendingText; + } outputEl.scrollTop = outputEl.scrollHeight; }, 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"; - // If the streamed chunks miss anything (e.g. asm tier - // which doesn't stream yet), reconcile with the full - // output. No-op when streaming captured everything. - if (fullOutput && pre.textContent !== fullOutput) { - pre.textContent = fullOutput; + // 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. + if (fullOutput) { + const got = Array.from(pre.children).map((d) => + d.textContent === " " ? "" : d.textContent).join("\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); + } + } } }, }; diff --git a/www/playground/app.js b/www/playground/app.js index 1f9d71e..67a172e 100644 --- a/www/playground/app.js +++ b/www/playground/app.js @@ -261,27 +261,63 @@ function startLiveBlock(tierName) { pre.textContent = ""; 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 = ""; + return { append(chunk) { - // Append a TextNode rather than `pre.textContent += chunk` - // — the latter re-reads + restringifies + re-sets all - // existing children on every chunk, which under a fast - // C-tier loop (one chunk per (newline) printf) was - // dropping line breaks and rendering "tick 0tick 10000…" - // horizontally instead of stacking vertically. TextNode - // append preserves every byte verbatim. - pre.appendChild(document.createTextNode(chunk)); + if (!chunk) return; + let start = 0; + for (let i = 0; i < chunk.length; i++) { + if (chunk.charCodeAt(i) === 10) { + pendingText += chunk.slice(start, i); + pendingLine.textContent = pendingText || " "; + pendingText = ""; + pendingLine = makeLine(); + pre.appendChild(pendingLine); + start = i + 1; + } + } + if (start < chunk.length) { + pendingText += chunk.slice(start); + pendingLine.textContent = pendingText; + } outputEl.scrollTop = outputEl.scrollHeight; }, 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"; - // If the streamed chunks miss anything (e.g. asm tier - // which doesn't stream yet), reconcile with the full - // output. No-op when streaming captured everything. - if (fullOutput && pre.textContent !== fullOutput) { - pre.textContent = fullOutput; + // 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. + if (fullOutput) { + const got = Array.from(pre.children).map((d) => + d.textContent === " " ? "" : d.textContent).join("\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); + } + } } }, };