Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/css-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 27 additions & 2 deletions src/helpers/code_highlighting_helper.js
Original file line number Diff line number Diff line change
@@ -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)
})
}

Expand Down
40 changes: 33 additions & 7 deletions test/javascript/unit/helpers/code_highlighting_helper.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<br>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")

Expand All @@ -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("<br>")

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)
Expand Down
Loading