fix(web): end the dollar escape run at a line ending

A candidate line ending in an odd backslash kept the escape run alive across
the line ending and any blank lines after it, so the next line's leading `$$`
counted as escaped content and bypassed the closing-fence check. Base kept the
later block as its own display formula; this branch swallowed it into one
KaTeX error block, and repeating the pair made the parse quadratic (12.8 KB
took 1.4 s, 25.6 KB took 8.2 s).

The escape run now ends where the line ending is consumed, which is what a
trailing backslash escapes. The pair parses as base does and repeated
candidates stay linear (25.6 KB in ~76 ms).

A block whose opening line carries a solitary `$` and that must interrupt a
paragraph is unchanged from the grammar this replaces: both flows decline the
interruption, so the block stays in the paragraph. The Agent Note records that
fourth shape with its workaround, and its Testing section now states each
guard's condition instead of the revision that produced it.
This commit is contained in:
Chinesezjc committed 2026-09-18 12:43:27 +08:00
1 parent 760ad03842
commit 0913c023bb
5 files changed
+44 -12

No files matched your search

@@ -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: 1a99ccdf433794a5e3c4f24a28e79e1caa0994b6
2026-09-17-multiline-display-dollar-math.zh.md: 7cabbd65a4423c71e512ac7ae03f87a2fcb35f37
2026-09-17-multiline-display-dollar-math.md: 2c3a796705131c18759f185ad93515c762af83bf
2026-09-17-multiline-display-dollar-math.zh.md: f0ad0240e78ae23da9c9dfed07ca5b64a89983fa
@@ -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.
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.
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.
@@ -37,7 +37,7 @@ Its paragraph-interruption check declines instead of consuming the line ending,
**Continue the dollar flow after a failed close.** That is what a plain `multiline` flag produces: `$$x$$ trailing\n\n$$y$$` then scans to the second block's `$$` and renders one math node holding both lines' text, where upstream inline math renders `x`, the prose, and `y` separately. Existing behavior for `$$x$$ trailing` already pinned the fallback, so the flow declines the candidate instead. Declining on any `$` rather than a full `$$` sequence also broke formulas that use a solitary `$` inside `\text{}`.
**End the attempt as soon as one line resists parsing.** A first revision declined every failed same-line close, which swallowed nothing but turned `$$a$b` lines into a quadratic scan and dropped the solitary-`$` formulas above. The condition is the full `$$` sequence, not the character.
**End the attempt as soon as one line resists parsing.** Declining every failed same-line close swallows nothing but turns `$$a$b` lines into a quadratic scan and drops the solitary-`$` formulas above. The condition is the full `$$` sequence, not the character.
**Claim the paragraph interruption in the check.** Returning success there, as upstream's flow does for the fences it accepts, prevents the lazy bookkeeping that broke the list case, but it also ends the paragraph for candidates that later fail (`**before\n$$a\$b\nafter**` split in two and exposed its `**`). Declining instead lets upstream's own check decide.
@@ -47,12 +47,14 @@ Its paragraph-interruption check declines instead of consuming the line ending,
The three shapes now produce the same math node, so formulas stop truncating at the opening line and stop swallowing the prose after them. `$$` fence meta is no longer honored: `$$asciimath` parses as a formula whose first line is `asciimath`, which upstream's flow would have split into meta plus body. That spelling has no user in this repository, and the renderer never read `meta` anyway.
Three inputs still reach upstream's flow, because the compatibility flow declines them. Trailing text after a closing `$$` (`$$x$$ trailing`, or a CJK quote mark closing a line of prose) resolves through upstream inline math, as the existing `markdown.client.spec.tsx` case pins — including when a later `$$` block follows. A `$$` sequence the line does not end after, met anywhere inside a block, declines that whole block to the same fallback. A `$$` block left open runs to the next lone `$$` or to EOF exactly as it did before this change, because the upstream flow closes at EOF.
Four inputs still reach upstream's flow or stay in the surrounding paragraph, because the compatibility flow declines them. Trailing text after a closing `$$` (`$$x$$ trailing`, or a CJK quote mark closing a line of prose) resolves through upstream inline math, as the existing `markdown.client.spec.tsx` case pins — including when a later `$$` block follows. A `$$` sequence the line does not end after, met anywhere inside a block, declines that whole block to the same fallback. A `$$` block left open runs to the next lone `$$` or to EOF exactly as it did before this change, because the upstream flow closes at EOF.
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 is what made an early revision quadratic (9.6 KB of `$$a$b` lines took ~4 s; it takes ~16 ms now). KaTeX-level failures in a formula's own source, such as `\arccot`, remain errors by design.
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. That behavior matches the grammar this replaced; only the multi-line shapes on their own paragraph 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. Five guards pin behavior this change must not alter: a trailing-text candidate ahead of a later block, a solitary `$` inside a display formula (including `\text{if $x>0$}` and `\tag{1}`), an escaped-dollar candidate that leaves its paragraph intact, an unclosed `\[` candidate that leaves its paragraph intact, and bounded parser work for repeated unclosed dollar candidates. The trailing-text, solitary-dollar, and escaped-dollar guards fail against the revision that introduced them; the bounded-work guard fails against the revision before this one.
`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. Seven guards 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 display formula and the same inside a multi-line one (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 guards 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.
@@ -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`)它放弃这次尝试:继续扫描会把更靠后一个块的闭合围栏也吞掉。孤立的一个 `$` 属于公式内容而不是失败的围栏,所以用到它的公式——`$$P = \begin{cases} 1 & \text{if $x>0$} \end{cases}$$`——保持块打开并正常闭合。
`$$` 序列后面不是该行行尾时(开围栏行或任意后续行,例如 `$$x$$ trailing`)它放弃这次尝试:继续扫描会把更靠后一个块的闭合围栏也吞掉。孤立的一个 `$` 属于公式内容而不是失败的围栏,所以凡是本 flow 拥有该块的场合,用到它的公式——`$$P = \begin{cases} 1 & \text{if $x>0$} \end{cases}$$`——保持块打开并正常闭合。行尾反斜杠转义的是换行本身,不是下一行的第一个字符,因此转义状态在换行处结束,不会延续到下一个候选。
它的段落打断检查改为「不认领」,且只作用于 dollar 构造。在那里消耗换行会让段落跨行边界保持打开,document 层因此保留容器并把下一行对随后尝试的所有构造计为 lazy;这正是上游 `mathFlow`(本构造之后被尝试的构造)在列表项内以空块结束的原因。不认领同时把打断决定交给上游 flow:它自己的检查不消耗换行就短路返回,并且只认领它接受的围栏,因此两个构造都不接受的候选(`**before` 后接 `$$a\$b`)保持原段落。反斜杠构造没有竞争的上游构造,仍然照旧消耗换行。
@@ -37,7 +37,7 @@ $$f(x)=x
**闭合失败后继续扫描。** 这正是单纯打开 `multiline` 会得到的行为:`$$x$$ trailing\n\n$$y$$` 会一路扫到第二个块的 `$$`,渲染成一个同时含着两行文本的 math 节点;而上游行内数学会把 `x`、正文和 `y` 分开渲染。`$$x$$ trailing` 的既有行为已经固定了这条回退,因此该构造放弃这个候选。对任意一个 `$`(而不是完整的 `$$` 序列)就放弃,还会破坏在 `\text{}` 内使用单个 `$` 的公式。
**只要有一行解析不下去就结束尝试。** 早期版本对每次同一行闭合失败都放弃,结果虽然不再吞文,却让 `$$a$b` 这类行变成平方级扫描,并丢掉上面那些含单个 `$` 的公式。判据是完整的 `$$` 序列,不是那个字符。
**只要有一行解析不下去就结束尝试。** 对每次同一行闭合失败都放弃虽然不再吞文,却会让 `$$a$b` 这类行变成平方级扫描,并丢掉上面那些含单个 `$` 的公式。判据是完整的 `$$` 序列,不是那个字符。
**在检查阶段认领段落打断。** 像上游 flow 那样返回成功可以避免破坏列表用例的 lazy 记账,但也会让最终失败的候选提前结束段落(`**before\n$$a\$b\nafter**` 被拆成两段并暴露 `**`)。改为不认领,把决定交给上游自己的检查。
@@ -47,12 +47,12 @@ $$f(x)=x
三种写法现在产生同一个 math 节点,公式不再在开围栏那一行被截断,也不再吞掉其后的正文。`$$` 围栏 meta 不再生效:`$$asciimath` 会解析成首行为 `asciimath` 的公式,而上游 flow 会把它拆成 meta 加正文。这种写法在本仓库没有使用者,且渲染器本来就从不读 `meta`。
有三种输入仍会走到上游 flow,因为兼容 flow 主动放弃它们。闭合 `$$` 之后还有文本时(`$$x$$ trailing`,或用中文引号收尾的散文行)走上游行内数学,这一点由 `markdown.client.spec.tsx` 中已有用例固定,后面还有别的 `$$` 块时同样如此。块内任意位置出现「后面不是行尾」的 `$$` 序列,会让整个块走到同一条回退。未闭合的 `$$` 块仍像本次改动之前那样延伸到下一个独占行 `$$` 或文件结尾,因为上游 flow 在 EOF 处闭合。
有四种输入仍会走到上游 flow,或留在所属段落里,因为兼容 flow 主动放弃它们。闭合 `$$` 之后还有文本时(`$$x$$ trailing`,或用中文引号收尾的散文行)走上游行内数学,这一点由 `markdown.client.spec.tsx` 中已有用例固定,后面还有别的 `$$` 块时同样如此。块内任意位置出现「后面不是行尾」的 `$$` 序列,会让整个块走到同一条回退。未闭合的 `$$` 块仍像本次改动之前那样延伸到下一个独占行 `$$` 或文件结尾,因为上游 flow 在 EOF 处闭合。
重复的未闭合候选保持线性解析开销:未闭合的候选会在下一行遇到无法闭合的完整 `$$` 时放弃,而不是把每个候选都扫到 EOF——那正是早期版本变成平方级的原因(9.6 KB 的 `$$a$b` 行约需 4 秒,现在约 16 毫秒)。公式自身源码层面的 KaTeX 失败,例如 `\arccot`,仍按设计报错。
第四种是「开围栏行含孤立 `$`、且需要打断段落、前面又没有空行」的多行块。两个 flow 都不认领这次打断——本 flow 出于设计,上游则因为它的围栏 meta 在 `$` 处即停止——该块因此留在段落里,渲染成行内数学或根本不渲染。用空行把块与段落分开,本 flow 就会拥有它并渲染 display。该行为与本次替换掉的语法一致;真正新增的只是「独立成段的多行写法」。公式自身源码层面的 KaTeX 失败,例如 `\arccot`,仍按设计报错。
## Testing
`packages/client/ui-primitives/tests/markdown.client.spec.tsx` 新增多行用例——开围栏行内容由内容行行尾 `$$` 闭合、由独占行 `$$` 闭合、出现在段落之后、出现在列表项内,以及列表项之后的零缩进围栏——该用例去掉改动即失败。另有五个守卫固定本次改动不得改变的行为:后面还有块时的尾随文本候选、显示公式内的单个 `$`(含 `\text{if $x>0$}` 与 `\tag{1}`)、保持段落完整性的转义美元候选、保持段落完整性的未闭合 `\[` 候选,以及重复未闭合美元候选的有界解析开销。前三个守卫在引入它们的版本上失败;有界开销守卫在更早的那一版上失败。
`packages/client/ui-primitives/tests/markdown.client.spec.tsx` 新增多行用例——开围栏行内容由内容行行尾 `$$` 闭合、由独占行 `$$` 闭合、出现在段落之后、出现在列表项内,以及列表项之后的零缩进围栏——该用例去掉改动即失败。另有七个守卫固定本次改动不得改变的行为,每个对应一个条件:后面还有块时的尾随文本候选(flow 必须放弃「后面不是行尾」的完整 `$$`)、显示公式内的单个 `$` 以及多行写法中的单个 `$`(不得因为一个 `$` 就放弃)、保持段落完整性的转义美元候选(打断检查不得认领段落)、保持段落完整性的未闭合 `\[` 候选(守卫不得作用于反斜杠构造)、以奇数个反斜杠结尾的候选(转义状态必须在换行处结束),以及带与不带该反斜杠两种重复候选的有界解析开销。
DOM 兼容语料新增 `math-multiline-display` 及其 `.settled.txt`、`.streaming.txt`,同时固定了「列表项之后的零缩进 `$$` 不进入列表项内」。`apps/web/tests/math-rendering.e2e.ts` 在浏览器 lane 渲染多行写法。
@@ -171,6 +171,9 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number)
)(code)
}
if (markdownLineEnding(code)) {
// A trailing backslash escapes the line ending, not the next line's
// first character, so the escape run ends here.
oddBackslashRun = 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
@@ -559,6 +559,33 @@ describe('MarkdownText', () => {
}
})
it('renders a multi-line display formula that uses a solitary dollar', () => {
const source = '$$P = \\begin{cases} 1 & \\text{if $x>0$} \\\\\n0 & \\text{otherwise} \\end{cases}$$\n\nAfter'
const { container } = render(<MarkdownText text={source} />)
expect(container.querySelectorAll('.katex-display')).toHaveLength(1)
expect(container.querySelector('.katex-error')).toBeNull()
expect(container.querySelectorAll('p')).toHaveLength(1)
expect(container.querySelector('p')?.textContent).toBe('After')
})
it('ends the backslash escape run at a line ending', () => {
const { container } = render(<MarkdownText text={'$$a$b\\\n\n$$y$$'} />)
expect(container.querySelectorAll('.katex-display')).toHaveLength(1)
expect(container.querySelector('.katex-display annotation')?.textContent).toBe('y')
expect(container.querySelector('.katex-error')).toBeNull()
})
it('bounds parser work for repeated candidates ending in an odd backslash', () => {
const source = '$$a$b\\\n\n'.repeat(3_200)
const startedAt = performance.now()
const tree = parseGfmWithMath(source)
expect(performance.now() - startedAt).toBeLessThan(3_000)
expect(tree.children.every(node => node.type !== 'math')).toBe(true)
})
it('keeps an escaped-dollar display candidate inside its paragraph', () => {
const { container } = render(<MarkdownText text={'**before\n$$a\\$b\nafter**'} />)