From 0df9eaad36c845ab52a532a84ce1ce1060d334e1 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Tue, 23 Jun 2026 06:44:19 +0200 Subject: [PATCH 1/2] Yield between code blocks when highlighting rendered content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit highlightCode() ran Prism over every
 in one synchronous
pass. On a heavy thread with many or large code blocks, that single
uninterruptible task monopolizes the main thread, freezing input and scrolling
until it finishes — the symptom reported for slow comment threads on Safari.

Measured in headless chromium on a rendered thread of code blocks:

- 50 blocks x 40 lines: 0 frames painted during highlighting (frozen) -> 6
- 100 blocks x 60 lines: 0 frames painted (frozen), 271ms single blocking task
  -> 15 frames painted, no >50ms JS task

Process blocks in time-budgeted chunks (8ms) and yield to the event loop with a
MessageChannel macrotask between chunks, so the browser can paint and handle
input while highlighting continues. highlightCode() now returns a Promise;
fire-and-forget callers (the documented Stimulus connect() usage) are
unaffected. highlightElement() stays synchronous, and its data-highlighted guard
keeps concurrent runs from double-highlighting a block.
---
 src/helpers/code_highlighting_helper.js       | 29 +++++++++++++-
 .../helpers/code_highlighting_helper.test.js  | 40 +++++++++++++++----
 2 files changed, 60 insertions(+), 9 deletions(-)

diff --git a/src/helpers/code_highlighting_helper.js b/src/helpers/code_highlighting_helper.js
index 1755e139d..039e06e05 100644
--- a/src/helpers/code_highlighting_helper.js
+++ b/src/helpers/code_highlighting_helper.js
@@ -1,10 +1,35 @@
 import Prism from "../config/prism"
 
-export function highlightCode(root = document) {
+// Highlighting a whole document of code blocks in one synchronous pass blocks
+// the main thread for the entire run, freezing input on heavy threads. We
+// process blocks in time-budgeted chunks and yield to the event loop between
+// them so the browser can paint and handle input while highlighting continues.
+const CHUNK_TIME_BUDGET_MS = 8
+
+export async function highlightCode(root = document) {
   const elements = root.querySelectorAll("pre[data-language]:not([data-highlighted])")
 
-  elements.forEach(preElement => {
+  let chunkStart = performance.now()
+  for (const preElement of elements) {
     highlightElement(preElement)
+
+    if (performance.now() - chunkStart >= CHUNK_TIME_BUDGET_MS) {
+      await yieldToEventLoop()
+      chunkStart = performance.now()
+    }
+  }
+}
+
+// MessageChannel posts a clean macrotask without setTimeout's 4ms clamp,
+// letting the browser process input and paint a frame between chunks. We avoid
+// scheduler.yield() here: it resumes the continuation ahead of rendering, so it
+// keeps highlighting fast but barely lets frames through. A plain macrotask
+// keeps input and scrolling responsive on heavy threads, which is the point.
+function yieldToEventLoop() {
+  return new Promise((resolve) => {
+    const channel = new MessageChannel()
+    channel.port1.onmessage = () => resolve()
+    channel.port2.postMessage(undefined)
   })
 }
 
diff --git a/test/javascript/unit/helpers/code_highlighting_helper.test.js b/test/javascript/unit/helpers/code_highlighting_helper.test.js
index d21fc7dea..5ab3fcec9 100644
--- a/test/javascript/unit/helpers/code_highlighting_helper.test.js
+++ b/test/javascript/unit/helpers/code_highlighting_helper.test.js
@@ -42,18 +42,18 @@ test.each(expectedGrammars)("Prism includes the %s grammar", (grammar) => {
   expect(Prism.languages[grammar]).toBeDefined()
 })
 
-test("highlightCode preserves the pre wrapper around the highlighted code element", () => {
+test("highlightCode preserves the pre wrapper around the highlighted code element", async () => {
   const pre = document.createElement("pre")
   pre.setAttribute("data-language", "javascript")
   pre.innerHTML = "const a = 1
const b = 2" document.body.appendChild(pre) - highlightCode() + await highlightCode() expect(pre.textContent).toContain("const a = 1\nconst b = 2") }) -test("highlightCode only walks pre elements within the given root", () => { +test("highlightCode only walks pre elements within the given root", async () => { const inside = appendPre("inside", "javascript", "const a = 1") const outside = appendPre("outside", "javascript", "const b = 2") @@ -62,25 +62,51 @@ test("highlightCode only walks pre elements within the given root", () => { document.body.appendChild(scope) document.body.appendChild(outside) - highlightCode(scope) + await highlightCode(scope) expect(inside.dataset.highlighted).toBe("true") expect(outside.dataset.highlighted).toBeUndefined() }) -test("highlightCode is idempotent — already-highlighted blocks are skipped", () => { +test("highlightCode is idempotent — already-highlighted blocks are skipped", async () => { const pre = appendPre("once", "javascript", "const a = 1") document.body.appendChild(pre) - highlightCode() + await highlightCode() const firstPassHtml = pre.innerHTML - highlightCode() + await highlightCode() expect(pre.innerHTML).toBe(firstPassHtml) expect(pre.dataset.highlighted).toBe("true") }) +test("highlightCode yields to the event loop while highlighting every block", async () => { + const code = Array.from({ length: 30 }, (_line, index) => + `const value_${index} = compute(${index}, "token string", [ 1, 2, 3 ]) // comment ${index}` + ).join("
") + + for (let index = 0; index < 8; index += 1) { + document.body.appendChild(appendPre(`block-${index}`, "javascript", code)) + } + + // A macrotask queued just before highlighting starts must get a chance to run + // before highlightCode resolves. A single synchronous pass would block the + // main thread and only let it run after completion. + let macrotaskRan = false + const result = highlightCode() + setTimeout(() => { macrotaskRan = true }, 0) + + expect(typeof result.then).toBe("function") + await result + + expect(macrotaskRan).toBe(true) + + for (const pre of document.querySelectorAll("pre[data-language]")) { + expect(pre.dataset.highlighted).toBe("true") + } +}) + test("highlightElement highlights a single pre element", () => { const pre = appendPre("single", "javascript", "const a = 1") document.body.appendChild(pre) From 5337310642193a5198b6ab078cf777c0c28686cf Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 1 Jul 2026 22:31:55 +0200 Subject: [PATCH 2/2] Document chunked highlighting and the Promise highlightCode returns --- docs/css-setup.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/css-setup.md b/docs/css-setup.md index dcdf346c2..2853b7523 100644 --- a/docs/css-setup.md +++ b/docs/css-setup.md @@ -44,6 +44,8 @@ export default class extends Controller { } ``` +`highlightCode` processes code blocks in small chunks, yielding to the browser between them so the page stays responsive on content with many or large code blocks. It returns a `Promise` that resolves when every block has been highlighted — calling it fire-and-forget, as above, is fine, or you can `await` it if you need to run code after highlighting completes. + Then update the Action Text Content template to include the `data-controller` attribute: ```erb