From c626afc757d5bb0df20b851fbff51fcef69bbf54 Mon Sep 17 00:00:00 2001 From: Thomas Pham Date: Wed, 26 Aug 2026 22:50:03 -0700 Subject: [PATCH 1/2] fix(web): highlight with the Oniguruma WASM engine, not the JS regex engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The JavaScript regex engine backtracks catastrophically on ordinary source lines. A 66-character Go comment line (containing braces and a non-ASCII dash) pinned the renderer main thread for 12+ minutes when the file preview panel restored a 476 KB / 10,915-line file: the editor's synchronous EditorTokenizer #buildStateStack loop calls tokenizeLine per line, and its 500 ms per-line limit cannot fire because vscode-textmate only checks the clock between regex matches — a single catastrophic match is uninterruptible. The whole window froze until force-quit (#8356). The same content tokenizes in 377 ms end-to-end on the Oniguruma WASM engine (worst single line 9.1 ms, measured via @pierre/diffs getSharedHighlighter on both engines). The shared highlighter is a first-caller-wins singleton, so every creation site must name the engine: the app's getSharedHighlighter call, the diff worker pool options, and each component options object whose FileRenderer can lazily initialize the singleton with the library's shiki-js default. PREFERRED_HIGHLIGHTER in syntaxHighlighting.ts names the decision once. The workers in DiffWorkerPoolProvider previously ran shiki-js as well, which plausibly explains the runnable-forever DedicatedWorkers in #3884. Verified: tsgo --noEmit clean; 2859 apps/web unit tests pass; production build emits the WASM engine as a lazy ~600 KB chunk; in the dev app the full repro file tokenizes in 385 ms in-browser where the JS engine previously hung indefinitely. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/DiffPanel.tsx | 2 ++ apps/web/src/components/DiffWorkerPoolProvider.tsx | 2 ++ apps/web/src/components/chat/MessagesTimeline.tsx | 2 ++ apps/web/src/components/files/FilePreviewPanel.tsx | 3 +++ .../components/pullRequest/PullRequestCodeTab.tsx | 2 ++ apps/web/src/lib/syntaxHighlighting.ts | 13 ++++++++++++- 6 files changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 53de0fdb8dd2..7a7e274999bf 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -37,6 +37,7 @@ import { resolveDiffThemeName, resolveFileDiffPath, } from "../lib/diffRendering"; +import { PREFERRED_HIGHLIGHTER } from "../lib/syntaxHighlighting"; import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "../lib/diffCollapse"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { useProject, useThread } from "../state/entities"; @@ -960,6 +961,7 @@ export default function DiffPanel({ lineDiffType: "none", overflow: wordWrap ? "wrap" : "scroll", theme: resolveDiffThemeName(resolvedTheme), + preferredHighlighter: PREFERRED_HIGHLIGHTER, themeType: resolvedTheme as DiffThemeType, stickyHeaders: true, ...(loadDiffFiles ? { loadDiffFiles } : {}), diff --git a/apps/web/src/components/DiffWorkerPoolProvider.tsx b/apps/web/src/components/DiffWorkerPoolProvider.tsx index 3ec748c6bcb2..bcf90b118410 100644 --- a/apps/web/src/components/DiffWorkerPoolProvider.tsx +++ b/apps/web/src/components/DiffWorkerPoolProvider.tsx @@ -4,6 +4,7 @@ import * as Schema from "effect/Schema"; import { useEffect, useMemo, type ReactNode } from "react"; import { useTheme } from "../hooks/useTheme"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; +import { PREFERRED_HIGHLIGHTER } from "../lib/syntaxHighlighting"; export class DiffWorkerError extends Schema.TaggedErrorClass()("DiffWorkerError", { operation: Schema.Literals(["create-worker", "get-render-options", "set-render-options"]), @@ -73,6 +74,7 @@ export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) { }} highlighterOptions={{ theme: diffThemeName, + preferredHighlighter: PREFERRED_HIGHLIGHTER, tokenizeMaxLineLength: 1_000, useTokenTransformer: true, }} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index af920c0d6156..de509a43363a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -43,6 +43,7 @@ import { resolveDiffThemeName, resolveFileDiffPath, } from "../../lib/diffRendering"; +import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting"; import ChatMarkdown from "../ChatMarkdown"; import { BotIcon, @@ -2078,6 +2079,7 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte collapsed: false, diffStyle: "unified", theme: resolveDiffThemeName(ctx.resolvedTheme), + preferredHighlighter: PREFERRED_HIGHLIGHTER, }} /> ))} diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index a8c364763c28..f98179027315 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -25,6 +25,7 @@ import { useClientSettings } from "~/hooks/useSettings"; import { useTheme } from "~/hooks/useTheme"; import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage"; import { DIFF_SURFACE_THEME_UNSAFE_CSS, resolveDiffThemeName } from "~/lib/diffRendering"; +import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting"; import { cn } from "~/lib/utils"; import { isPreviewSupportedInRuntime } from "~/previewStateStore"; import { resolvePathLinkTarget } from "~/terminal-links"; @@ -670,6 +671,7 @@ function EditableFileSurface({ onLineSelectionEnd: handleLineSelectionEnd, overflow: wordWrap ? "wrap" : "scroll", theme: resolveDiffThemeName(resolvedTheme), + preferredHighlighter: PREFERRED_HIGHLIGHTER, themeType: resolvedTheme, unsafeCSS: FILE_LINK_REVEAL_UNSAFE_CSS, onPostRender: handlePostRender, @@ -1039,6 +1041,7 @@ export default function FilePreviewPanel({ disableFileHeader: true, overflow: wordWrap ? "wrap" : "scroll", theme: resolveDiffThemeName(resolvedTheme), + preferredHighlighter: PREFERRED_HIGHLIGHTER, themeType: resolvedTheme, unsafeCSS: FILE_LINK_REVEAL_UNSAFE_CSS, onPostRender: onFilePostRender, diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index b0e00d57cc61..d9f1e7af716d 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -42,6 +42,7 @@ import { resolveFileDiffPreviousPath, type RenderablePatch, } from "~/lib/diffRendering"; +import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting"; import { cn } from "~/lib/utils"; import { createPullRequestDiffFileContentsLoader } from "~/lib/diffFileContents"; import { @@ -743,6 +744,7 @@ export function PullRequestCodeTab({ lineDiffType: "none" as const, overflow: wordWrap ? ("wrap" as const) : ("scroll" as const), theme: resolveDiffThemeName(resolvedTheme), + preferredHighlighter: PREFERRED_HIGHLIGHTER, themeType: resolvedTheme, stickyHeaders: true, loadDiffFiles, diff --git a/apps/web/src/lib/syntaxHighlighting.ts b/apps/web/src/lib/syntaxHighlighting.ts index 171725617e80..80cfcb70bd7f 100644 --- a/apps/web/src/lib/syntaxHighlighting.ts +++ b/apps/web/src/lib/syntaxHighlighting.ts @@ -1,11 +1,22 @@ import { getSharedHighlighter, type DiffsHighlighter, + type HighlighterTypes, type SupportedLanguages, } from "@pierre/diffs"; import { resolveDiffThemeName } from "./diffRendering"; +/** + * Always highlight with the Oniguruma WASM engine. The JavaScript regex engine + * can backtrack catastrophically on ordinary source lines (a Go comment + * containing `{}` and a non-ASCII dash pinned the renderer main thread for 12+ + * minutes; the same input tokenizes in under 10ms on WASM). The shared + * highlighter is a first-caller-wins singleton, so every creation site must + * pass this value. + */ +export const PREFERRED_HIGHLIGHTER: HighlighterTypes = "shiki-wasm"; + const highlighterPromiseCache = new Map>(); export function getSyntaxHighlighterPromise(language: string): Promise { @@ -15,7 +26,7 @@ export function getSyntaxHighlighterPromise(language: string): Promise { if (language === "text") { highlighterPromiseCache.delete(language); From 5e5651ccba9e0704ae167742e3ce52b2292ff6bf Mon Sep 17 00:00:00 2001 From: Thomas Pham Date: Wed, 26 Aug 2026 23:44:34 -0700 Subject: [PATCH 2/2] fix(web): cover the SSR settings preview highlighter site; trim engine comment Macroscope review: SettingsFontPreviews' preloadPatchFile call reaches DiffHunksRenderer.initializeHighlighter, which creates the shared first-caller-wins singleton with the library's shiki-js default. Opening Appearance settings before any diff/file surface would pin the JS regex engine for the whole session. Pass PREFERRED_HIGHLIGHTER there too, and trim the incident detail out of the constant's doc comment. --- apps/web/src/components/settings/SettingsFontPreviews.tsx | 3 ++- apps/web/src/lib/syntaxHighlighting.ts | 6 ++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/settings/SettingsFontPreviews.tsx b/apps/web/src/components/settings/SettingsFontPreviews.tsx index 57d714d2042d..856d85fb098a 100644 --- a/apps/web/src/components/settings/SettingsFontPreviews.tsx +++ b/apps/web/src/components/settings/SettingsFontPreviews.tsx @@ -5,6 +5,7 @@ import { terminalThemeFromApp } from "../ThreadTerminalDrawer"; import { useTheme } from "../../hooks/useTheme"; import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; import { resolveDiffThemeName, type DiffThemeName } from "../../lib/diffRendering"; +import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting"; import { GhosttyTerminalSurface } from "~/terminal/ghostty/surface"; // The font previews are the real surfaces, not lookalikes: the composer's @@ -79,7 +80,7 @@ function loadDiffPreviewHtml(theme: DiffThemeName): Promise { if (promise === undefined) { promise = preloadPatchFile({ patch: DIFF_PREVIEW_PATCH, - options: { diffStyle: "unified", theme }, + options: { diffStyle: "unified", theme, preferredHighlighter: PREFERRED_HIGHLIGHTER }, }).then((results) => results.map((result) => result.prerenderedHTML)); diffPreviewHtmlByTheme.set(theme, promise); } diff --git a/apps/web/src/lib/syntaxHighlighting.ts b/apps/web/src/lib/syntaxHighlighting.ts index 80cfcb70bd7f..3be008ac2b79 100644 --- a/apps/web/src/lib/syntaxHighlighting.ts +++ b/apps/web/src/lib/syntaxHighlighting.ts @@ -8,10 +8,8 @@ import { import { resolveDiffThemeName } from "./diffRendering"; /** - * Always highlight with the Oniguruma WASM engine. The JavaScript regex engine - * can backtrack catastrophically on ordinary source lines (a Go comment - * containing `{}` and a non-ASCII dash pinned the renderer main thread for 12+ - * minutes; the same input tokenizes in under 10ms on WASM). The shared + * Always highlight with the Oniguruma WASM engine — the JS regex engine can + * backtrack catastrophically and hang the tokenizing thread. The shared * highlighter is a first-caller-wins singleton, so every creation site must * pass this value. */