Skip to content

Commit b22ffa9

Browse files
committed
fix(math): enhance KaTeX rendering with caching and debug support
1 parent cde9c69 commit b22ffa9

File tree

5 files changed

+243
-23
lines changed

5 files changed

+243
-23
lines changed

src/components/MathBlockNode/MathBlockNode.vue

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script setup lang="ts">
2+
import katex from 'katex'
23
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
3-
import { renderKaTeXInWorker } from '../../workers/katexWorkerClient'
4+
import { renderKaTeXInWorker, setKaTeXCache } from '../../workers/katexWorkerClient'
45
56
const props = defineProps<{
67
node: {
@@ -49,9 +50,27 @@ function renderMath() {
4950
return
5051
if (!mathBlockElement.value)
5152
return
52-
// show raw fallback when we never successfully rendered before or when loading flag is false
53-
if (!hasRenderedOnce || !props.node.loading) {
54-
mathBlockElement.value.textContent = props.node.raw
53+
// Try a synchronous KaTeX render on the main thread as a fallback
54+
try {
55+
const html = katex.renderToString(props.node.content, {
56+
throwOnError: true,
57+
displayMode: true,
58+
})
59+
mathBlockElement.value.innerHTML = html
60+
hasRenderedOnce = true
61+
// populate worker client cache so future calls hit cache
62+
try {
63+
setKaTeXCache(props.node.content, true, html)
64+
}
65+
catch {
66+
// ignore cache set errors
67+
}
68+
}
69+
catch {
70+
// show raw fallback when we never successfully rendered before or when loading flag is false
71+
if (!hasRenderedOnce || !props.node.loading) {
72+
mathBlockElement.value.textContent = props.node.raw
73+
}
5574
}
5675
})
5776
}

src/components/MathInlineNode/MathInlineNode.vue

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script setup lang="ts">
2+
import katex from 'katex'
23
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
3-
import { renderKaTeXInWorker } from '../../workers/katexWorkerClient'
4+
import { renderKaTeXInWorker, setKaTeXCache } from '../../workers/katexWorkerClient'
45
56
const props = defineProps<{
67
node: {
@@ -44,8 +45,25 @@ function renderMath() {
4445
return
4546
if (!mathElement.value)
4647
return
47-
if (!hasRenderedOnce || !props.node.loading)
48-
mathElement.value.textContent = props.node.raw
48+
// Try synchronous render as a fallback
49+
try {
50+
const html = katex.renderToString(props.node.content, {
51+
throwOnError: true,
52+
displayMode: false,
53+
})
54+
mathElement.value.innerHTML = html
55+
hasRenderedOnce = true
56+
try {
57+
setKaTeXCache(props.node.content, false, html)
58+
}
59+
catch {
60+
// ignore cache set errors
61+
}
62+
}
63+
catch {
64+
if (!hasRenderedOnce || !props.node.loading)
65+
mathElement.value.textContent = props.node.raw
66+
}
4967
})
5068
}
5169

src/utils/markdown.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import markdownItSub from 'markdown-it-sub'
88
import markdownItSup from 'markdown-it-sup'
99
import * as markdownItCheckbox from 'markdown-it-task-checkbox'
1010
import { useSafeI18n } from '../composables/useSafeI18n'
11-
import { renderKaTeXInWorker } from '../workers/katexWorkerClient'
11+
import { renderKaTeXInWorker, setKaTeXCache } from '../workers/katexWorkerClient'
1212

1313
import {
1414
parseInlineTokens,
@@ -210,10 +210,17 @@ export async function renderMarkdownAsync(md: MarkdownIt, content: string) {
210210
}
211211
catch {
212212
try {
213-
return katex.renderToString(latex, {
213+
const data = katex.renderToString(latex, {
214214
throwOnError: true,
215215
displayMode: false,
216216
})
217+
try {
218+
setKaTeXCache(latex, false, data)
219+
}
220+
catch {
221+
// ignore cache set errors
222+
}
223+
return data
217224
}
218225
catch {
219226
return null
Lines changed: 66 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import katex from 'katex'
22

33
interface MessageIn {
4-
id: string
5-
content: string
4+
// supports two shapes: init messages and render messages
5+
type?: 'init' | 'render'
6+
id?: string
7+
content?: string
68
displayMode?: boolean
9+
debug?: boolean
710
}
811

912
interface MessageOut {
@@ -12,11 +15,32 @@ interface MessageOut {
1215
error?: string
1316
}
1417

15-
// eslint-disable-next-line no-restricted-globals
16-
self.addEventListener('message', (ev: MessageEvent<MessageIn>) => {
17-
const { id, content, displayMode = true } = ev.data
18+
let DEBUG = false
19+
20+
;(globalThis as any).addEventListener('message', (ev: MessageEvent<MessageIn>) => {
21+
const data = ev.data || {}
22+
if (data.type === 'init') {
23+
DEBUG = !!data.debug
24+
try {
25+
if (DEBUG)
26+
console.debug('[katexRenderer.worker] debug enabled')
27+
}
28+
catch {}
29+
return
30+
}
31+
32+
const id = data.id ?? ''
33+
const content = data.content ?? ''
34+
const displayMode = data.displayMode ?? true
1835

1936
try {
37+
try {
38+
// note: use console for visibility in DevTools when debugging worker
39+
if (DEBUG)
40+
console.debug('[katexRenderer.worker] render start', { id, displayMode, content })
41+
}
42+
catch {}
43+
2044
// renderToString is CPU-bound but doesn't touch the DOM, so it's safe in a worker
2145
const html = katex.renderToString(content, {
2246
throwOnError: true,
@@ -26,13 +50,45 @@ self.addEventListener('message', (ev: MessageEvent<MessageIn>) => {
2650
})
2751

2852
const out: MessageOut & { content: string, displayMode: boolean } = { id, html, content, displayMode }
29-
// send back the generated HTML and original input for caching
30-
// eslint-disable-next-line no-restricted-globals
31-
;(self as any).postMessage(out)
53+
try {
54+
// send back the generated HTML and original input for caching
55+
56+
;(globalThis as any).postMessage(out)
57+
try {
58+
if (DEBUG)
59+
console.debug('[katexRenderer.worker] render success', { id })
60+
}
61+
catch {}
62+
}
63+
catch (postErr) {
64+
try {
65+
console.error('[katexRenderer.worker] failed to postMessage result', postErr)
66+
}
67+
catch {}
68+
}
3269
}
3370
catch (err: any) {
3471
const out: MessageOut & { content: string, displayMode: boolean } = { id, error: String(err?.message ?? err), content, displayMode }
35-
// eslint-disable-next-line no-restricted-globals
36-
;(self as any).postMessage(out)
72+
try {
73+
;(globalThis as any).postMessage(out)
74+
}
75+
catch (postErr) {
76+
try {
77+
console.error('[katexRenderer.worker] failed to postMessage error', postErr)
78+
}
79+
catch {}
80+
}
81+
}
82+
})
83+
84+
// Catch any uncaught errors in the worker and attempt to inform the main thread
85+
;(globalThis as any).addEventListener('error', (ev: ErrorEvent) => {
86+
try {
87+
console.error('[katexRenderer.worker] uncaught error', ev.message, ev.error)
88+
}
89+
catch {}
90+
try {
91+
;(globalThis as any).postMessage({ id: '__worker_uncaught__', error: String(ev.message ?? ev.error), content: '', displayMode: true })
3792
}
93+
catch {}
3894
})

src/workers/katexWorkerClient.ts

Lines changed: 124 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ interface Pending {
55
}
66

77
let worker: Worker | null = null
8+
// runtime debug flag controlled by the main thread
9+
let DEBUG_KATEX_WORKER = false
10+
// telemetry / diagnostics listeners for worker errors/timeouts
11+
const errorListeners = new Set<(err: any) => void>()
812
const pending = new Map<string, Pending>()
913
// Simple in-memory cache to avoid repeated renders for identical input.
1014
const cache = new Map<string, string>()
@@ -17,27 +21,53 @@ function ensureWorker() {
1721
// Only create a Worker in a browser environment
1822
if (typeof window === 'undefined') {
1923
worker = null
24+
try {
25+
console.warn('[katexWorkerClient] window is undefined — Web Worker will not be created')
26+
}
27+
catch {}
2028
}
2129
else {
2230
// Vite-friendly worker instantiation. Bundlers will inline the worker when configured.
2331
worker = new Worker(new URL('./katexRenderer.worker.ts', import.meta.url), { type: 'module' })
32+
try {
33+
// send initial debug flag to worker so it can gate debug logs
34+
;(worker as any).postMessage({ type: 'init', debug: DEBUG_KATEX_WORKER })
35+
}
36+
catch {}
2437
}
2538
}
2639
catch {
2740
worker = null
41+
try {
42+
console.warn('[katexWorkerClient] failed to instantiate Web Worker')
43+
}
44+
catch {}
2845
}
2946

3047
if (worker) {
3148
worker.addEventListener('message', (ev: MessageEvent) => {
3249
const { id, html, error, content, displayMode } = ev.data as any
3350
const p = pending.get(id)
3451
if (!p) {
52+
try {
53+
console.warn('[katexWorkerClient] received message for unknown id', id, { content, displayMode, error })
54+
}
55+
catch {}
3556
return
3657
}
3758
(globalThis as any).clearTimeout(p.timeoutId)
3859
pending.delete(id)
3960
if (error) {
40-
p.reject(new Error(error))
61+
const err = new Error(String(error))
62+
;(err as any).name = 'WorkerRenderError'
63+
;(err as any).code = 'WORKER_RENDER_ERROR'
64+
;(err as any).content = content
65+
;(err as any).displayMode = displayMode
66+
try {
67+
console.warn('[katexWorkerClient] worker returned an error for id', id, String(error))
68+
}
69+
catch {}
70+
p.reject(err)
4171
return
4272
}
4373

@@ -51,17 +81,79 @@ function ensureWorker() {
5181
cache.delete(firstKey)
5282
}
5383
}
54-
catch {
55-
// ignore cache errors
84+
catch (e) {
85+
try {
86+
console.warn('[katexWorkerClient] cache set failed', e)
87+
}
88+
catch {}
5689
}
5790

5891
p.resolve(html)
5992
})
93+
94+
worker.addEventListener('error', (ev) => {
95+
try {
96+
console.error('[katexWorkerClient] Worker error', ev)
97+
}
98+
catch {}
99+
// reject all pending promises so callers can fallback
100+
for (const [_id, p] of pending.entries()) {
101+
try {
102+
const err = new Error('Worker crashed')
103+
;(err as any).name = 'WorkerCrashed'
104+
;(err as any).code = 'WORKER_CRASHED'
105+
try {
106+
for (const h of errorListeners) {
107+
try {
108+
h(err)
109+
}
110+
catch {}
111+
}
112+
}
113+
catch {}
114+
p.reject(err)
115+
}
116+
catch {}
117+
}
118+
pending.clear()
119+
})
120+
121+
worker.addEventListener('messageerror', (ev) => {
122+
try {
123+
console.error('[katexWorkerClient] Worker messageerror', ev)
124+
}
125+
catch {}
126+
})
60127
}
61128

62129
return worker
63130
}
64131

132+
// Allow toggling verbose worker debug logs at runtime. When set, we post an init
133+
// message to an existing worker so the worker can enable logs.
134+
export function setKaTeXWorkerDebug(enabled: boolean) {
135+
DEBUG_KATEX_WORKER = !!enabled
136+
if (worker) {
137+
try {
138+
;(worker as any).postMessage({ type: 'init', debug: DEBUG_KATEX_WORKER })
139+
}
140+
catch {
141+
try {
142+
console.warn('[katexWorkerClient] failed to send debug init to worker')
143+
}
144+
catch {}
145+
}
146+
}
147+
}
148+
149+
export function onKaTeXWorkerError(fn: (err: any) => void) {
150+
errorListeners.add(fn)
151+
}
152+
153+
export function offKaTeXWorkerError(fn: (err: any) => void) {
154+
errorListeners.delete(fn)
155+
}
156+
65157
export async function renderKaTeXInWorker(content: string, displayMode = true, timeout = 2000, signal?: AbortSignal): Promise<string> {
66158
// Quick cache hit
67159
const cacheKey = `${displayMode ? 'd' : 'i'}:${content}`
@@ -84,7 +176,19 @@ export async function renderKaTeXInWorker(content: string, displayMode = true, t
84176
const id = Math.random().toString(36).slice(2)
85177
const timeoutId = (globalThis as any).setTimeout(() => {
86178
pending.delete(id)
87-
reject(new Error('Worker render timed out'))
179+
const err = new Error('Worker render timed out')
180+
;(err as any).name = 'WorkerTimeout'
181+
;(err as any).code = 'WORKER_TIMEOUT'
182+
try {
183+
for (const h of errorListeners) {
184+
try {
185+
h(err)
186+
}
187+
catch {}
188+
}
189+
}
190+
catch {}
191+
reject(err)
88192
}, timeout)
89193

90194
// Listen for abort to cancel this pending request
@@ -105,6 +209,22 @@ export async function renderKaTeXInWorker(content: string, displayMode = true, t
105209
})
106210
}
107211

212+
// Allow callers (e.g. main-thread fallback renderers) to populate the internal cache
213+
// so that synchronous renders can benefit subsequent worker-based calls.
214+
export function setKaTeXCache(content: string, displayMode = true, html: string) {
215+
try {
216+
const cacheKey = `${displayMode ? 'd' : 'i'}:${content}`
217+
cache.set(cacheKey, html)
218+
if (cache.size > CACHE_MAX) {
219+
const firstKey = cache.keys().next().value
220+
cache.delete(firstKey)
221+
}
222+
}
223+
catch {
224+
// ignore cache errors
225+
}
226+
}
227+
108228
// When a worker response arrives we set the cache (handled in ensureWorker message handler),
109229
// but the handler does not currently set cache; to keep cache coherent, also set here by
110230
// wrapping the Promise resolution above would be ideal. However, pending resolution occurs

0 commit comments

Comments
 (0)