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)