fix(web): decline a dollar block whose closing fence carries text
Review of the multi-line change found three behaviors it had altered: - `$$x$$ trailing\n\n$$y$$` scanned on from the failed same-line close and swallowed the second block's fence, rendering one broken math node where inline math, the prose, and the display block belong. A `$$` the flow cannot close now ends the attempt, as the single-line case already did. - The paragraph-interruption guard sat in the shared tokenizer body, so an unclosed `\[` candidate (`**before\n\[literal]\nafter**`) split its paragraph and exposed the `**` markers. The guard applies to the dollar flow only, which is the one with a competing upstream construct. - Repeated unclosed candidates (`$$a$b` per line) scanned to EOF from every line, making 9.6 KB of them take about four seconds in a synchronous parse. Declining the candidate on its opening line restores linear work (about 18 ms). Each behavior gains a guard test that fails against the previous revision.
This commit is contained in:
1 parent
f723892c98
commit
14ead62052
6 files changed
+82
-25
No files matched your search
+2
-2
@@ -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: 1fbeacae41e2b50599c27dd2d62252a9db1f27f7
|
||||
2026-09-17-multiline-display-dollar-math.zh.md: 4433f93e5adfb38fd52127df43217b20fad5ba24
|
||||
2026-09-17-multiline-display-dollar-math.md: e7d6b6a7285dd22c316dd4537a0bbdfd674aeb49
|
||||
2026-09-17-multiline-display-dollar-math.zh.md: ea86476c56345f3158e66299206691bfc4301871
|
||||
@@ -19,9 +19,11 @@ In the session that surfaced this, one chat reply lost lines 22–55 of 55 to a
|
||||
|
||||
## Decision
|
||||
|
||||
[mathCompatibility.ts](../../../../packages/client/ui-primitives/src/markdown/mathCompatibility.ts) owns every `$$` flow fence. Its dollar construct spans multiple lines, so an opening `$$` followed by math on the same line closes at a trailing `$$` on any later content line, and the opening line's text stays in `mathFlowValue` instead of becoming fence meta. Its paragraph-interruption check returns before the line ending, as `micromark-extension-math`'s own flow does, because consuming it there marks the next line lazy for the constructs tried afterwards and made the upstream flow end empty inside a list item.
|
||||
[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 also ends the attempt when a `$$` on the opening line cannot close — `$$x$$ trailing` — because scanning on from there would consume a later block's closing fence.
|
||||
|
||||
[parse.ts](../../../../packages/client/ui-primitives/src/markdown/parse.ts) registers `mathCompatibility()` after `math()`: micromark dispatches the extension registered last first, so that order is what lets the compatibility flow claim `$$` while leaving `math()` to own inline `$…$`.
|
||||
Its paragraph-interruption check returns before the line ending for the dollar construct only. Consuming the line ending 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. The backslash construct shares neither that competing upstream construct nor the problem, so it keeps its previous interruption behavior.
|
||||
|
||||
[parse.ts](../../../../packages/client/ui-primitives/src/markdown/parse.ts) registers `mathCompatibility()` after `math()`: micromark dispatches the extension registered last first, so the dollar flow above takes precedence over upstream's `mathFlow` while `math()` still owns inline `$…$`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -31,14 +33,20 @@ In the session that surfaced this, one chat reply lost lines 22–55 of 55 to a
|
||||
|
||||
**Rewrite `$$` fences in the source text before parsing.** The compatibility layer is a grammar extension by design; a text pass would need to locate code spans, fences, and escapes itself, and would change author text rather than parse it.
|
||||
|
||||
**Reorder without the interruption check.** Registering the compatibility flow first fixes the formula but made upstream's flow end empty inside a list item, because the failed interruption check advanced the parser's lazy table before the second construct ran.
|
||||
**Continue the dollar flow after a failed same-line 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.
|
||||
|
||||
**Apply the interruption check to the backslash construct too.** The guard exists to protect the construct tried after this one; `\[`/`\(` have no second construct, and the shared guard split `**before\n\[literal]\nafter**` into two paragraphs and exposed its `**` markers.
|
||||
|
||||
## Consequences
|
||||
|
||||
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.
|
||||
|
||||
Trailing text after a closing `$$` (`$$x$$ trailing`, or a CJK quote mark closing a line of prose) still resolves through upstream inline math; the existing `markdown.client.spec.tsx` case pins that behavior. KaTeX-level failures in a formula's own source, such as `\arccot`, remain errors by design.
|
||||
Two shapes 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 `$$` 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: the compatibility flow declines `$$a$b` on its opening line instead of scanning each candidate to EOF, which is what made the first revision of this change quadratic (9.6 KB of such lines took ~4 s; it takes ~18 ms now). 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` covers the six shapes (same-line, opening-line content with a content-line close, content-line close alone, paragraph interruption, list item, and column-zero fence after a list item) and fails without the change. 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.
|
||||
`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. Three guards pin behavior this change must not alter, and each fails against the first revision: a trailing-text candidate ahead of a later block, an unclosed `\[` candidate inside one paragraph, and bounded parser work for repeated unclosed dollar candidates.
|
||||
|
||||
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.
|
||||
@@ -19,9 +19,11 @@ $$f(x)=x
|
||||
|
||||
## Decision
|
||||
|
||||
[mathCompatibility.ts](../../../../packages/client/ui-primitives/src/markdown/mathCompatibility.ts) 拥有所有 `$$` flow 围栏。它的 dollar 构造跨多行:开围栏 `$$` 后同一行的公式内容可以延续到后续内容行,由行尾的 `$$` 闭合;开围栏那一行的文本留在 `mathFlowValue` 里,不再变成围栏 meta。它的段落打断检查在换行符之前返回,与 `micromark-extension-math` 自己的 flow 一致;因为在那里消耗换行会把下一行对随后尝试的构造标记为 lazy,使上游 flow 在列表项内以空块结束。
|
||||
[mathCompatibility.ts](../../../../packages/client/ui-primitives/src/markdown/mathCompatibility.ts) 现在支持跨行的显示美元块。dollar 构造把开围栏同一行的文本留在 `mathFlowValue` 里而不是当作围栏 meta,并在后续任意内容行行尾的 `$$` 处闭合,于是 `$$a+b` 接 `c+d$$` 成为一个 math 节点。开围栏那一行的 `$$` 无法闭合时(`$$x$$ trailing`)它直接放弃这次尝试:继续扫描会把更靠后一个块的闭合围栏也吞掉。
|
||||
|
||||
[parse.ts](../../../../packages/client/ui-primitives/src/markdown/parse.ts) 把 `mathCompatibility()` 注册在 `math()` 之后:micromark 先派发最后注册的扩展,这个顺序让兼容 flow 认领 `$$`,同时把行内 `$…$` 留给 `math()`。
|
||||
它的段落打断检查只在 dollar 构造上于换行之前返回。在那里消耗换行会让段落跨行边界保持打开,document 层因此保留容器并把下一行对随后尝试的所有构造计为 lazy;这正是上游 `mathFlow`(本构造之后被尝试的构造)在列表项内以空块结束的原因。反斜杠构造既没有那个竞争的上游构造、也没有这个问题,因此保持原有的打断行为。
|
||||
|
||||
[parse.ts](../../../../packages/client/ui-primitives/src/markdown/parse.ts) 把 `mathCompatibility()` 注册在 `math()` 之后:micromark 先派发最后注册的扩展,上面的 dollar flow 因此优先于上游 `mathFlow`,而行内 `$…$` 仍由 `math()` 负责。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -31,14 +33,20 @@ $$f(x)=x
|
||||
|
||||
**解析前用文本替换改写 `$$` 围栏。** 兼容层的设计就是语法扩展;文本替换需要自己定位代码 span、围栏和转义,而且改的是作者原文而不是解析它。
|
||||
|
||||
**只调整注册顺序、不加打断检查。** 让兼容 flow 优先能修好公式,但会让上游 flow 在列表项内以空块结束——失败的打字检查在被第二个构造尝试之前就把解析器的 lazy 表推进了。
|
||||
**同一行闭合失败后继续扫描。** 这正是单纯打开 `multiline` 会得到的行为:`$$x$$ trailing\n\n$$y$$` 会一路扫到第二个块的 `$$`,渲染成一个同时含着两行文本的 math 节点;而上游行内数学会把 `x`、正文和 `y` 分开渲染。`$$x$$ trailing` 的既有行为已经固定了这条回退,因此该构造放弃这个候选。
|
||||
|
||||
**把打断检查也用在反斜杠构造上。** 这个提前返回是为了保护随后被尝试的构造;`\[`/`\(` 没有第二个构造,共用它会把握 `**before\n\[literal]\nafter**` 拆成两个段落并暴露 `**` 标记。
|
||||
|
||||
## Consequences
|
||||
|
||||
三种写法现在产生同一个 math 节点,公式不再在开围栏那一行被截断,也不再吞掉其后的正文。`$$` 围栏 meta 不再生效:`$$asciimath` 会解析成首行为 `asciimath` 的公式,而上游 flow 会把它拆成 meta 加正文。这种写法在本仓库没有使用者,且渲染器本来就从不读 `meta`。
|
||||
|
||||
闭合 `$$` 之后还有文本的情况(`$$x$$ trailing`,或用中文引号收尾的散文行)仍走上游行内数学;`markdown.client.spec.tsx` 中已有的用例固定了该行为。公式自身源码层面的 KaTeX 失败,例如 `\arccot`,仍按设计报错。
|
||||
有两种形状仍会走到上游 flow,因为兼容 flow 主动放弃它们。闭合 `$$` 之后还有文本时(`$$x$$ trailing`,或用中文引号收尾的散文行)走上游行内数学,这一点由 `markdown.client.spec.tsx` 中已有用例固定,后面还有别的 `$$` 块时同样如此。未闭合的 `$$` 块仍像本次改动之前那样延伸到下一个独占行 `$$` 或文件结尾,因为上游 flow 在 EOF 处闭合。
|
||||
|
||||
重复的未闭合候选保持线性解析开销:兼容 flow 在开围栏那一行就放弃 `$$a$b`,而不是把每个候选都扫到 EOF——那正是本次改动的第一版变成平方级的原因(9.6 KB 这样的行约需 4 秒,现在约 18 毫秒)。公式自身源码层面的 KaTeX 失败,例如 `\arccot`,仍按设计报错。
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/client/ui-primitives/tests/markdown.client.spec.tsx` 覆盖六种形状(同一行开闭、开围栏行内容加内容行闭合、闭合围栏独占一行、打断段落、列表项内、列表项之后的零缩进围栏),去掉改动即失败。DOM 兼容语料新增 `math-multiline-display` 及其 `.settled.txt`、`.streaming.txt`,同时固定了「列表项之后的零缩进 `$$` 不进入列表项内」。`apps/web/tests/math-rendering.e2e.ts` 在浏览器 lane 渲染多行写法。
|
||||
`packages/client/ui-primitives/tests/markdown.client.spec.tsx` 新增多行用例——开围栏行内容由内容行行尾 `$$` 闭合、由独占行 `$$` 闭合、出现在段落之后、出现在列表项内,以及列表项之后的零缩进围栏——该用例去掉改动即失败。另有三个守卫固定本次改动不得改变的行为,且都在第一版上失败:后面还有块时的尾随文本候选、单个段落内的未闭合 `\[` 候选、以及重复未闭合美元候选的有界解析开销。
|
||||
|
||||
DOM 兼容语料新增 `math-multiline-display` 及其 `.settled.txt`、`.streaming.txt`,同时固定了「列表项之后的零缩进 `$$` 不进入列表项内」。`apps/web/tests/math-rendering.e2e.ts` 在浏览器 lane 渲染多行写法。
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* Extend upstream dollar-only math syntax with TeX delimiters while reusing its
|
||||
* token vocabulary. The dollar construct claims every `$$` flow fence, so text
|
||||
* on the opening line is math content instead of the upstream construct's fence
|
||||
* meta, which the renderer does not read.
|
||||
* token vocabulary. The dollar construct is dispatched before upstream's
|
||||
* `mathFlow`, so the shapes it accepts keep the opening line's text as math
|
||||
* instead of fence meta, which the renderer does not read. A `$$` candidate it
|
||||
* declines — a fence the opening line cannot close, or a block left open — is
|
||||
* left to that upstream flow.
|
||||
*/
|
||||
|
||||
import { factorySpace } from 'micromark-factory-space'
|
||||
@@ -169,19 +171,24 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number)
|
||||
}
|
||||
if (markdownLineEnding(code)) {
|
||||
// A paragraph-interruption check must stop before the line ending, as
|
||||
// `micromark-extension-math`'s flow does: consuming it here marks the
|
||||
// next line lazy for every construct tried afterwards.
|
||||
return self.interrupt
|
||||
? ok(code)
|
||||
: effects.attempt(nonLazyContinuation, afterContinuation, nok)(code)
|
||||
// `micromark-extension-math`'s flow does: consuming it here 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. Only the dollar flow faces that second construct.
|
||||
if (self.interrupt && marker === codes.dollarSign) return ok(code)
|
||||
return effects.attempt(nonLazyContinuation, afterContinuation, nok)(code)
|
||||
}
|
||||
return valueStart(code)
|
||||
}
|
||||
|
||||
function afterClosingFenceAttempt(code: number | null): State | undefined {
|
||||
return marker === codes.backslash
|
||||
? effects.check({ partial: true, tokenize: tokenizeOpeningFence }, nok, markerValueStart)(code)
|
||||
: markerValueStart(code)
|
||||
if (marker === codes.backslash) {
|
||||
return effects.check({ partial: true, tokenize: tokenizeOpeningFence }, nok, markerValueStart)(code)
|
||||
}
|
||||
// A `$$` with text after it closes nothing: `$$x$$ trailing` is not a
|
||||
// display block, and scanning on from here would swallow a later block's
|
||||
// closing fence. Upstream inline math owns such text.
|
||||
return nok(code)
|
||||
}
|
||||
|
||||
function afterContinuation(code: number | null): State | undefined {
|
||||
|
||||
@@ -39,8 +39,8 @@ export function parseGfm(text: string): Root {
|
||||
export function parseGfmWithMath(text: string): Root {
|
||||
return fromMarkdown(text, {
|
||||
// `mathCompatibility()` MUST stay last: micromark dispatches the extension
|
||||
// registered last first, and the dollar flow must own every `$$` fence so
|
||||
// the opening line's text stays math instead of upstream's fence meta.
|
||||
// registered last first, so its dollar flow takes precedence over `math()`
|
||||
// and keeps the opening line's text as math instead of upstream's fence meta.
|
||||
extensions: [gfm(), cjkFriendlyStrong(), math(), mathCompatibility()],
|
||||
mdastExtensions: [gfmFromMarkdown(), mathFromMarkdown()],
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { JsonBlock, MarkdownText } from './markdown-test-components.tsx'
|
||||
import { LinkIcon, MarkdownDelegateProvider } from '../src/index.ts'
|
||||
import { cjkFriendlyStrong } from '../src/markdown/cjkFriendlyStrong.ts'
|
||||
import { mathCompatibility } from '../src/markdown/mathCompatibility.ts'
|
||||
import { parseGfmWithMath } from '../src/markdown/parse.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -509,6 +510,39 @@ describe('MarkdownText', () => {
|
||||
expect(rendered.container.querySelector('.katex-error')).toBeNull()
|
||||
rendered.unmount()
|
||||
}
|
||||
|
||||
const followed = render(<MarkdownText text={'$$a+b\nc+d$$\n\nAfter'} />)
|
||||
expect(followed.container.querySelectorAll('.katex-display')).toHaveLength(1)
|
||||
expect(followed.container.querySelectorAll('p')).toHaveLength(1)
|
||||
expect(followed.container.querySelector('p')?.textContent).toBe('After')
|
||||
followed.unmount()
|
||||
})
|
||||
|
||||
it('leaves a dollar candidate with trailing text to inline math ahead of a later block', () => {
|
||||
const { container } = render(<MarkdownText text={'$$x$$ trailing\n\n$$y$$'} />)
|
||||
|
||||
expect(container.querySelectorAll('.katex')).toHaveLength(2)
|
||||
expect(container.querySelectorAll('.katex-display')).toHaveLength(1)
|
||||
expect(container.querySelector('.katex-display annotation')?.textContent).toBe('y')
|
||||
expect(container.querySelector('.katex-error')).toBeNull()
|
||||
expect(container.textContent).toContain('trailing')
|
||||
})
|
||||
|
||||
it('keeps an unclosed backslash candidate inside one paragraph', () => {
|
||||
const { container } = render(<MarkdownText text={'**before\n\\[literal]\nafter**'} />)
|
||||
|
||||
expect(container.querySelectorAll('p')).toHaveLength(1)
|
||||
expect(container.querySelector('strong')).not.toBeNull()
|
||||
expect(container.textContent).not.toContain('**')
|
||||
})
|
||||
|
||||
it('bounds parser work for repeated unclosed dollar candidates', () => {
|
||||
const source = '$$a$b\n'.repeat(1_600)
|
||||
const startedAt = performance.now()
|
||||
const tree = parseGfmWithMath(source)
|
||||
|
||||
expect(performance.now() - startedAt).toBeLessThan(3_000)
|
||||
expect(JSON.stringify(tree)).not.toContain('math-display')
|
||||
})
|
||||
|
||||
it('lets display math interrupt an open paragraph', () => {
|
||||
|
||||
Reference in new issue
Block a user