diff --git a/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.i18n.yaml
index 30029384c1..8f06903c07 100644
--- a/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.i18n.yaml
+++ b/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.md
-2026-09-17-multiline-display-dollar-math.md: 28aaa8e85b02d908121ede5e59bad3c59912f686
-2026-09-17-multiline-display-dollar-math.zh.md: dcb731aebc3612ac7070255488586959286537c0
+2026-09-17-multiline-display-dollar-math.md: 38b005a2570e3420b9c81ff882c38d4d29cfb026
+2026-09-17-multiline-display-dollar-math.zh.md: b2f875ff7ad8b664ee19df6f16641ebc37bbf4b9
diff --git a/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.md b/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.md
index 28aaa8e85b..38b005a257 100644
--- a/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.md
+++ b/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.md
@@ -21,7 +21,7 @@ In the session that surfaced this, one chat reply lost lines 22–55 of 55 to a
[mathCompatibility.ts](../../../../packages/client/ui-primitives/src/markdown/mathCompatibility.ts) gains multi-line display-dollar blocks. Its dollar construct keeps the opening line's text in `mathFlowValue` instead of fence meta and closes at a trailing `$$` on any later content line, so `$$a+b` continued by `c+d$$` becomes one math node.
-It ends the attempt when a `$$` sequence is not followed by the line's end, on the opening line or any later one (`$$x$$ trailing`), because scanning on from there would consume a later block's closing fence. A solitary `$` is content rather than a failed fence, so a formula that uses one — `$$P = \begin{cases} 1 & \text{if $x>0$} \end{cases}$$` — keeps the block open and closes normally wherever this flow owns the block. A trailing backslash escapes the line ending, not the next line's first character, so the escape run ends at the line ending instead of reaching into the next candidate.
+It ends the attempt when a `$$` sequence is not followed by the line's end, on the opening line or any later one (`$$x$$ trailing`), because scanning on from there would consume a later block's closing fence. A solitary `$` is content rather than a failed fence, so a formula that uses one — `$$P = \begin{cases} 1 & \text{if $x>0$} \end{cases}$$` — keeps the block open and closes normally wherever this flow owns the block. A trailing backslash escapes the line ending, not the next line's first character, so the escape run ends at the line ending instead of reaching into the next candidate. An unescaped `%` starts a TeX comment that runs to the line ending, so a `$$` inside one closes nothing either.
Its paragraph-interruption check declines instead of consuming the line ending, for the dollar construct only. Consuming it there keeps the paragraph open across the line boundary, so the document layer keeps the container and counts the next line lazy for every construct tried afterwards; that made upstream's `mathFlow`, the construct tried after this one, end empty inside a list item. Declining also leaves the interruption decision to that upstream flow, whose own check short-circuits without consuming and claims only the fences it accepts, so a candidate neither construct accepts (`**before` followed by `$$a\$b`) leaves its paragraph intact. The backslash construct has no competing upstream construct and keeps consuming there.
@@ -51,12 +51,12 @@ Five inputs still reach upstream's flow or stay in the surrounding paragraph, be
The fourth input is a multi-line block whose opening line carries a solitary `$` and that must interrupt a paragraph, with no blank line before it. Both flows decline the interruption there — this one by design, upstream because its fence meta stops at the `$` — so the block stays inside the paragraph and renders as inline math or not at all. Separating the block from the paragraph with a blank line makes this flow own it and render the display block.
-The fifth is a multi-line block inside a container whose continuation line falls back to the container's indentation. `- Define:` followed by `$$f(x)=x` at the item's indentation and then a de-indented continuation line gives upstream's flow a fence meta and no value, so the reader sees an empty display block and the continuation as prose — the opening line is gone. Keeping the continuation at the same indentation, or moving the block out of the list, renders it normally. Both flow behaviors match the grammar this replaced; only the multi-line shapes on their own paragraph are new.
+The fifth is a multi-line block inside a container whose continuation line falls back to the container's indentation. `- Define:` followed by `$$f(x)=x` at the item's indentation and then a de-indented continuation line gives upstream's flow a fence meta and no value, so the reader sees an empty display block and the continuation as prose — the opening line is gone. Keeping the continuation at the same indentation, or moving the block out of the list, renders it normally. Both flow behaviors match the grammar this replaced; only the multi-line blocks this flow owns are new.
Repeated unclosed candidates keep linear parser work: an open candidate declines at the next line that starts with a full `$$` it cannot close, rather than scanning each candidate to EOF, which would make 9.6 KB of `$$a$b` lines take ~4 s instead of ~16 ms. KaTeX-level failures in a formula's own source, such as `\arccot`, remain errors by design.
## Testing
-`packages/client/ui-primitives/tests/markdown.client.spec.tsx` adds the multi-line case — opening-line math closed on a content line, closed by a lone fence, after a paragraph, inside a list item, and at column zero after a list item — and that case fails without the change. The same file also pins a multi-line formula that uses a solitary `$`, which like the case above renders only with this change. Seven further cases pin behavior this change must not alter, each against one condition: a trailing-text candidate ahead of a later block (the flow must decline a full `$$` the line does not end after), a solitary `$` inside a single-line display formula (the flow must not decline on a single `$`), an escaped-dollar candidate that leaves its paragraph intact (the interruption check must not claim the paragraph), an unclosed `\[` candidate that leaves its paragraph intact (the guard must not apply to the backslash construct), a candidate ending in an odd backslash (the escape run must end at the line ending), and two bounded-work cases for repeated candidates with and without that backslash.
+`packages/client/ui-primitives/tests/markdown.client.spec.tsx` adds the multi-line case — opening-line math closed on a content line, closed by a lone fence, after a paragraph, inside a list item, and at column zero after a list item — and that case fails without the change. The same file also pins a multi-line formula that uses a solitary `$`, which like the case above renders only with this change. Seven further cases pin behavior this change must not alter, each against one condition: a trailing-text candidate ahead of a later block (the flow must decline a full `$$` the line does not end after), a solitary `$` inside a single-line display formula (the flow must not decline on a single `$`), an escaped-dollar candidate that leaves its paragraph intact (the interruption check must not claim the paragraph), an unclosed `\[` candidate that leaves its paragraph intact (the guard must not apply to the backslash construct), a candidate ending in an odd backslash (the escape run must end at the line ending), a closing fence inside a TeX comment (an unescaped `%` must suppress it), and two bounded-work cases for repeated candidates with and without that backslash.
The DOM compatibility corpus adds `math-multiline-display` with its `.settled.txt` and `.streaming.txt` fixtures, which also pin that a column-zero `$$` after a list item stays outside the list item. `apps/web/tests/math-rendering.e2e.ts` renders the multi-line shape in the browser lane.
diff --git a/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.zh.md b/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.zh.md
index dcb731aebc..b2f875ff7a 100644
--- a/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.zh.md
+++ b/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.zh.md
@@ -21,7 +21,7 @@ $$f(x)=x
[mathCompatibility.ts](../../../../packages/client/ui-primitives/src/markdown/mathCompatibility.ts) 现在支持跨行的显示美元块。dollar 构造把开围栏同一行的文本留在 `mathFlowValue` 里而不是当作围栏 meta,并在后续任意内容行行尾的 `$$` 处闭合,于是 `$$a+b` 接 `c+d$$` 成为一个 math 节点。
-`$$` 序列后面不是该行行尾时(开围栏行或任意后续行,例如 `$$x$$ trailing`)它放弃这次尝试:继续扫描会把更靠后一个块的闭合围栏也吞掉。孤立的一个 `$` 属于公式内容而不是失败的围栏,所以凡是本 flow 拥有该块的场合,用到它的公式——`$$P = \begin{cases} 1 & \text{if $x>0$} \end{cases}$$`——保持块打开并正常闭合。行尾反斜杠转义的是换行本身,不是下一行的第一个字符,因此转义状态在换行处结束,不会延续到下一个候选。
+`$$` 序列后面不是该行行尾时(开围栏行或任意后续行,例如 `$$x$$ trailing`)它放弃这次尝试:继续扫描会把更靠后一个块的闭合围栏也吞掉。孤立的一个 `$` 属于公式内容而不是失败的围栏,所以凡是本 flow 拥有该块的场合,用到它的公式——`$$P = \begin{cases} 1 & \text{if $x>0$} \end{cases}$$`——保持块打开并正常闭合。行尾反斜杠转义的是换行本身,不是下一行的第一个字符,因此转义状态在换行处结束,不会延续到下一个候选。未转义的 `%` 会开启延续到行尾的 TeX 注释,注释里的 `$$` 同样不闭合任何东西。
它的段落打断检查改为「不认领」,且只作用于 dollar 构造。在那里消耗换行会让段落跨行边界保持打开,document 层因此保留容器并把下一行对随后尝试的所有构造计为 lazy;这正是上游 `mathFlow`(本构造之后被尝试的构造)在列表项内以空块结束的原因。不认领同时把打断决定交给上游 flow:它自己的检查不消耗换行就短路返回,并且只认领它接受的围栏,因此两个构造都不接受的候选(`**before` 后接 `$$a\$b`)保持原段落。反斜杠构造没有竞争的上游构造,仍然照旧消耗换行。
@@ -51,12 +51,12 @@ $$f(x)=x
第四种是「开围栏行含孤立 `$`、且需要打断段落、前面又没有空行」的多行块。两个 flow 都不认领这次打断——本 flow 出于设计,上游则因为它的围栏 meta 在 `$` 处即停止——该块因此留在段落里,渲染成行内数学或根本不渲染。用空行把块与段落分开,本 flow 就会拥有它并渲染 display。
-第五种是容器内续行退回容器缩进的多行块。`- Define:` 之后以列表项缩进写 `$$f(x)=x`,再接一行不缩进的续行,上游 flow 会得到有 meta、无 value 的结果:读者看到一个空的 display 块加一行散文,公式首行无声消失。续行保持同样缩进,或把整块移到列表之外,即可正常渲染。这两种 flow 行为都与本次替换掉的语法一致;真正新增的只是「独立成段的多行写法」。
+第五种是容器内续行退回容器缩进的多行块。`- Define:` 之后以列表项缩进写 `$$f(x)=x`,再接一行不缩进的续行,上游 flow 会得到有 meta、无 value 的结果:读者看到一个空的 display 块加一行散文,公式首行无声消失。续行保持同样缩进,或把整块移到列表之外,即可正常渲染。这两种 flow 行为都与本次替换掉的语法一致;真正新增的只是「本 flow 拥有的多行块」。
重复的未闭合候选保持线性解析开销:未闭合的候选会在下一行遇到无法闭合的完整 `$$` 时放弃,而不是把每个候选都扫到 EOF——否则 9.6 KB 的 `$$a$b` 行会花约 4 秒而不是约 16 毫秒。公式自身源码层面的 KaTeX 失败,例如 `\arccot`,仍按设计报错。
## Testing
-`packages/client/ui-primitives/tests/markdown.client.spec.tsx` 新增多行用例——开围栏行内容由内容行行尾 `$$` 闭合、由独占行 `$$` 闭合、出现在段落之后、出现在列表项内,以及列表项之后的零缩进围栏——该用例去掉改动即失败。同一文件还固定了「使用单个 `$` 的多行公式」,它与上面的多行用例一样只有本次改动才会渲染成 display。另有七个用例固定本次改动不得改变的行为,每个对应一个条件:后面还有块时的尾随文本候选(flow 必须放弃「后面不是行尾」的完整 `$$`)、单行显示公式内的单个 `$`(不得因为一个 `$` 就放弃)、保持段落完整性的转义美元候选(打断检查不得认领段落)、保持段落完整性的未闭合 `\[` 候选(守卫不得作用于反斜杠构造)、以奇数个反斜杠结尾的候选(转义状态必须在换行处结束),以及带与不带该反斜杠两种重复候选的有界解析开销。
+`packages/client/ui-primitives/tests/markdown.client.spec.tsx` 新增多行用例——开围栏行内容由内容行行尾 `$$` 闭合、由独占行 `$$` 闭合、出现在段落之后、出现在列表项内,以及列表项之后的零缩进围栏——该用例去掉改动即失败。同一文件还固定了「使用单个 `$` 的多行公式」,它与上面的多行用例一样只有本次改动才会渲染成 display。另有七个用例固定本次改动不得改变的行为,每个对应一个条件:后面还有块时的尾随文本候选(flow 必须放弃「后面不是行尾」的完整 `$$`)、单行显示公式内的单个 `$`(不得因为一个 `$` 就放弃)、保持段落完整性的转义美元候选(打断检查不得认领段落)、保持段落完整性的未闭合 `\[` 候选(守卫不得作用于反斜杠构造)、以奇数个反斜杠结尾的候选(转义状态必须在换行处结束)、TeX 注释内的闭合围栏(未转义的 `%` 必须抑制它),以及带与不带该反斜杠两种重复候选的有界解析开销。
DOM 兼容语料新增 `math-multiline-display` 及其 `.settled.txt`、`.streaming.txt`,同时固定了「列表项之后的零缩进 `$$` 不进入列表项内」。`apps/web/tests/math-rendering.e2e.ts` 在浏览器 lane 渲染多行写法。
diff --git a/packages/client/ui-primitives/src/markdown/mathCompatibility.ts b/packages/client/ui-primitives/src/markdown/mathCompatibility.ts
index b9e11cd27c..b0de40bcd6 100644
--- a/packages/client/ui-primitives/src/markdown/mathCompatibility.ts
+++ b/packages/client/ui-primitives/src/markdown/mathCompatibility.ts
@@ -131,6 +131,7 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number)
const tokenize: Tokenizer = function (effects, ok, nok) {
const self = this
let oddBackslashRun = false
+ let inComment = false
let sawClosingFenceSequence = false
const tail = self.events.at(-1)
const initialSize = tail?.[1].type === types.linePrefix
@@ -163,7 +164,7 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number)
function content(code: number | null): State | undefined {
if (code === codes.eof) return nok(code)
- if (code === marker && (marker !== codes.dollarSign || !oddBackslashRun)) {
+ if (code === marker && (marker !== codes.dollarSign || (!oddBackslashRun && !inComment))) {
return effects.attempt(
{ partial: true, tokenize: tokenizeClosingFence },
closed,
@@ -172,8 +173,10 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number)
}
if (markdownLineEnding(code)) {
// A trailing backslash escapes the line ending, not the next line's
- // first character, so the escape run ends here.
+ // first character, so the escape run ends here, and a TeX comment ends
+ // with the line.
oddBackslashRun = false
+ inComment = false
// A paragraph-interruption check on the dollar flow must not consume the
// line ending: that keeps the paragraph open across the line boundary,
// and the document layer then counts the next line lazy for every
@@ -210,7 +213,10 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number)
function valueStart(code: number | null): State | undefined {
effects.enter('mathFlowValue')
+ // An unescaped `%` opens a TeX comment wherever it is the first character
+ // of a value run, such as a line that starts inside the block.
oddBackslashRun = code === codes.backslash
+ if (code === codes.percentSign) inComment = true
effects.consume(code)
return value
}
@@ -235,7 +241,11 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number)
effects.exit('mathFlowValue')
return content(code)
}
+ const escaped = oddBackslashRun
oddBackslashRun = code === codes.backslash ? !oddBackslashRun : false
+ // An unescaped `%` starts a TeX comment that runs to the line ending, so
+ // a `$$` inside it closes nothing.
+ if (!escaped && code === codes.percentSign) inComment = true
effects.consume(code)
return value
}
diff --git a/packages/client/ui-primitives/tests/markdown.client.spec.tsx b/packages/client/ui-primitives/tests/markdown.client.spec.tsx
index a5e83a918e..1ff662613b 100644
--- a/packages/client/ui-primitives/tests/markdown.client.spec.tsx
+++ b/packages/client/ui-primitives/tests/markdown.client.spec.tsx
@@ -569,6 +569,32 @@ describe('MarkdownText', () => {
expect(container.querySelector('p')?.textContent).toBe('After')
})
+ it('ignores a closing fence inside a TeX comment', () => {
+ const { container } = render()
+
+ expect(container.querySelectorAll('.katex-display')).toHaveLength(1)
+ expect(container.querySelector('.katex-display annotation')?.textContent)
+ .toBe('\\frac{1}{ % keep $$\n2}')
+ expect(container.querySelector('.katex-error')).toBeNull()
+ expect(container.querySelectorAll('p')).toHaveLength(1)
+ expect(container.querySelector('p')?.textContent).toBe('After')
+ })
+
+ it('keeps a TeX comment from closing the block wherever it starts', () => {
+ for (const source of [
+ '$$\n% keep $$\n\\frac12\n$$\n\nAfter',
+ '$$% keep $$\n\\frac12\n$$\n\nAfter',
+ ]) {
+ const rendered = render()
+
+ expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(1)
+ expect(rendered.container.querySelector('.katex-error')).toBeNull()
+ expect(rendered.container.querySelectorAll('p')).toHaveLength(1)
+ expect(rendered.container.querySelector('p')?.textContent).toBe('After')
+ rendered.unmount()
+ }
+ })
+
it('ends the backslash escape run at a line ending', () => {
const { container } = render()