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
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)