Skip to content

Commit b41779b

Browse files
committed
feat: Enhance math rendering with abort signal and cleanup for trailing backticks
1 parent 73f22fc commit b41779b

File tree

7 files changed

+134
-14
lines changed

7 files changed

+134
-14
lines changed

src/components/CodeBlockNode/CodeBlockNode.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ const usePreCodeRender = ref(false)
133133
safeClean = helpers.cleanupEditor || safeClean
134134
setTheme = helpers.setTheme || setTheme
135135
136-
if (!editorCreated.value && codeEditor.value && createEditor) {
136+
if (!editorCreated.value && codeEditor.value) {
137137
editorCreated.value = true
138138
isDiff.value
139139
? createDiffEditor(codeEditor.value as HTMLElement, props.node.originalCode || '', props.node.updatedCode || '', codeLanguage.value)

src/components/MathBlockNode/MathBlockNode.vue

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
import { onMounted, ref, watch } from 'vue'
2+
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
33
import { renderKaTeXInWorker } from '../../workers/katexWorkerClient'
44
55
const props = defineProps<{
@@ -13,20 +13,43 @@ const props = defineProps<{
1313
1414
const mathBlockElement = ref<HTMLElement | null>(null)
1515
let hasRenderedOnce = false
16+
let currentRenderId = 0
17+
let isUnmounted = false
18+
let currentAbortController: AbortController | null = null
1619
1720
// Function to render math using KaTeX
1821
function renderMath() {
19-
if (!props.node.content || !mathBlockElement.value)
22+
if (!props.node.content || !mathBlockElement.value || isUnmounted)
2023
return
2124
22-
renderKaTeXInWorker(props.node.content, true, 3000)
25+
// cancel any previous in-flight render
26+
if (currentAbortController) {
27+
currentAbortController.abort()
28+
currentAbortController = null
29+
}
30+
31+
// increment render id for this invocation; responses from older renders are ignored
32+
const renderId = ++currentRenderId
33+
const abortController = new AbortController()
34+
currentAbortController = abortController
35+
36+
renderKaTeXInWorker(props.node.content, true, 3000, abortController.signal)
2337
.then((html) => {
38+
// ignore if a newer render was requested or component unmounted
39+
if (isUnmounted || renderId !== currentRenderId)
40+
return
41+
if (!mathBlockElement.value)
42+
return
2443
mathBlockElement.value.innerHTML = html
2544
hasRenderedOnce = true
2645
})
2746
.catch(() => {
47+
// ignore if a newer render was requested or component unmounted
48+
if (isUnmounted || renderId !== currentRenderId)
49+
return
2850
if (!mathBlockElement.value)
2951
return
52+
// show raw fallback when we never successfully rendered before or when loading flag is false
3053
if (!hasRenderedOnce || !props.node.loading) {
3154
mathBlockElement.value.textContent = props.node.raw
3255
}
@@ -42,6 +65,13 @@ watch(
4265
onMounted(() => {
4366
renderMath()
4467
})
68+
69+
onBeforeUnmount(() => {
70+
// prevent any pending worker responses from touching the DOM
71+
isUnmounted = true
72+
// increment id so any in-flight render is considered stale
73+
currentRenderId++
74+
})
4575
</script>
4676

4777
<template>

src/components/MathInlineNode/MathInlineNode.vue

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
import { onMounted, ref, watch } from 'vue'
2+
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
33
import { renderKaTeXInWorker } from '../../workers/katexWorkerClient'
44
55
const props = defineProps<{
@@ -13,17 +13,35 @@ const props = defineProps<{
1313
1414
const mathElement = ref<HTMLElement | null>(null)
1515
let hasRenderedOnce = false
16+
let currentRenderId = 0
17+
let isUnmounted = false
18+
let currentAbortController: AbortController | null = null
1619
1720
function renderMath() {
18-
if (!props.node.content || !mathElement.value)
21+
if (!props.node.content || !mathElement.value || isUnmounted)
1922
return
2023
21-
renderKaTeXInWorker(props.node.content, false, 1500)
24+
if (currentAbortController) {
25+
currentAbortController.abort()
26+
currentAbortController = null
27+
}
28+
29+
const renderId = ++currentRenderId
30+
const abortController = new AbortController()
31+
currentAbortController = abortController
32+
33+
renderKaTeXInWorker(props.node.content, false, 1500, abortController.signal)
2234
.then((html) => {
35+
if (isUnmounted || renderId !== currentRenderId)
36+
return
37+
if (!mathElement.value)
38+
return
2339
mathElement.value.innerHTML = html
2440
hasRenderedOnce = true
2541
})
2642
.catch(() => {
43+
if (isUnmounted || renderId !== currentRenderId)
44+
return
2745
if (!mathElement.value)
2846
return
2947
if (!hasRenderedOnce || !props.node.loading)
@@ -41,6 +59,14 @@ watch(
4159
onMounted(() => {
4260
renderMath()
4361
})
62+
63+
onBeforeUnmount(() => {
64+
isUnmounted = true
65+
if (currentAbortController) {
66+
currentAbortController.abort()
67+
currentAbortController = null
68+
}
69+
})
4470
</script>
4571

4672
<template>

src/components/NodeRenderer/NodeRenderer.vue

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,9 +151,10 @@ function getNodeComponent(node: BaseNode) {
151151
return FallbackComponent
152152
if (node.type === 'code_block') {
153153
const lang = String((node as any).language || '').trim().toLowerCase()
154-
const custom = getCustomNodeComponents(props.customId).mermaid
155-
if (lang === 'mermaid')
154+
if (lang === 'mermaid') {
155+
const custom = getCustomNodeComponents(props.customId).mermaid
156156
return (custom as any) || MermaidBlockNode
157+
}
157158
return nodeComponents.code_block
158159
}
159160
return (nodeComponents as any)[node.type] || FallbackComponent

src/utils/markdown-parser/inline-parsers/fence-parser.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,28 @@ export function parseFenceToken(token: MarkdownToken): CodeBlockNode {
3333
const closed = typeof meta?.closed === 'boolean' ? meta.closed : undefined
3434
const diff = token.info?.startsWith('diff') || false
3535
const language = diff ? token.info.split(' ')[1] || '' : token.info || ''
36+
37+
// Defensive sanitization: sometimes a closing fence line (e.g. ``` or ``)
38+
// can accidentally end up inside `token.content` (for example when
39+
// the parser/mapping is confused). Remove a trailing line that only
40+
// contains backticks and optional whitespace so we don't render stray
41+
// ` or `` characters at the end of the code output. This is a
42+
// conservative cleanup and only strips a final line that looks like a
43+
// fence marker (starts with optional spaces then one or more ` and
44+
// only whitespace until end-of-string).
45+
let content = token.content || ''
46+
const trailingFenceLine = /\r?\n[ \t]*`+\s*$/
47+
if (trailingFenceLine.test(content))
48+
content = content.replace(trailingFenceLine, '')
49+
3650
if (diff) {
37-
const { original, updated } = splitUnifiedDiff(token.content || '')
51+
const { original, updated } = splitUnifiedDiff(content)
3852
// 返回时保留原来的 code 字段为 updated(编辑后代码),并额外附加原始与更新的文本
3953
return {
4054
type: 'code_block',
4155
language,
4256
code: updated || '',
43-
raw: token.content || '',
57+
raw: content,
4458
diff,
4559
loading: closed === true ? false : closed === false ? true : !hasMap,
4660
originalCode: original,
@@ -51,8 +65,8 @@ export function parseFenceToken(token: MarkdownToken): CodeBlockNode {
5165
return {
5266
type: 'code_block',
5367
language,
54-
code: token.content || '',
55-
raw: token.content || '',
68+
code: content || '',
69+
raw: content || '',
5670
diff,
5771
loading: closed === true ? false : closed === false ? true : !hasMap,
5872
}

src/workers/katexWorkerClient.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ function ensureWorker() {
5555
return worker
5656
}
5757

58-
export async function renderKaTeXInWorker(content: string, displayMode = true, timeout = 2000): Promise<string> {
58+
export async function renderKaTeXInWorker(content: string, displayMode = true, timeout = 2000, signal?: AbortSignal): Promise<string> {
5959
// Quick cache hit
6060
const cacheKey = `${displayMode ? 'd' : 'i'}:${content}`
6161
const cached = cache.get(cacheKey)
@@ -67,12 +67,31 @@ export async function renderKaTeXInWorker(content: string, displayMode = true, t
6767
return Promise.reject(new Error('Web Worker not available'))
6868

6969
return new Promise((resolve, reject) => {
70+
if (signal?.aborted) {
71+
// align with DOM abort semantics
72+
const err = new Error('Aborted')
73+
;(err as any).name = 'AbortError'
74+
reject(err)
75+
return
76+
}
7077
const id = Math.random().toString(36).slice(2)
7178
const timeoutId = window.setTimeout(() => {
7279
pending.delete(id)
7380
reject(new Error('Worker render timed out'))
7481
}, timeout)
7582

83+
// Listen for abort to cancel this pending request
84+
const onAbort = () => {
85+
window.clearTimeout(timeoutId)
86+
if (pending.has(id))
87+
pending.delete(id)
88+
const err = new Error('Aborted')
89+
;(err as any).name = 'AbortError'
90+
reject(err)
91+
}
92+
if (signal)
93+
signal.addEventListener('abort', onAbort, { once: true })
94+
7695
pending.set(id, { resolve, reject, timeoutId })
7796

7897
wk.postMessage({ id, content, displayMode })
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { parseFenceToken } from '../src/utils/markdown-parser/inline-parsers/fence-parser'
3+
4+
describe('fence parser trailing fence cleanup', () => {
5+
it('removes a trailing line that only contains backticks from token.content', () => {
6+
const token: any = {
7+
type: 'fence',
8+
info: 'ts',
9+
content: 'const a = 1\n```',
10+
map: [0, 2],
11+
}
12+
13+
const node = parseFenceToken(token as any)
14+
expect(node).toBeDefined()
15+
expect((node as any).code).toBe('const a = 1')
16+
expect((node as any).raw).toBe('const a = 1')
17+
})
18+
19+
it('keeps legitimate content that contains backticks not on their own line', () => {
20+
const token: any = {
21+
type: 'fence',
22+
info: 'text',
23+
content: 'console.log(\'`inline`)\n', // backtick inside code, not a fence line
24+
map: [0, 2],
25+
}
26+
const node = parseFenceToken(token as any)
27+
expect((node as any).code).toBe('console.log(\'`inline`)\n')
28+
expect((node as any).raw).toBe('console.log(\'`inline`)\n')
29+
})
30+
})

0 commit comments

Comments
 (0)