From cd8e98fdf9b1f3c58aa3037d2c7dd4efac528e56 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:29:09 -0700 Subject: [PATCH] fix: prevent mobile markdown parser from stalling on unsupported blocks (#20313) --- .../mobile-markdown-parser-progress.test.ts | 46 +++++++++++++++++++ .../src/components/mobile-markdown-parser.ts | 11 +++-- 2 files changed, 53 insertions(+), 4 deletions(-) create mode 100644 mobile/src/components/mobile-markdown-parser-progress.test.ts diff --git a/mobile/src/components/mobile-markdown-parser-progress.test.ts b/mobile/src/components/mobile-markdown-parser-progress.test.ts new file mode 100644 index 00000000000..e5cf3181d0c --- /dev/null +++ b/mobile/src/components/mobile-markdown-parser-progress.test.ts @@ -0,0 +1,46 @@ +import { runInNewContext } from 'node:vm' +import { describe, expect, it } from 'vitest' +import { normalizeMobileMarkdownPreviewHtml } from './mobile-markdown-preview-html' +import { parseMobileMarkdown } from './mobile-markdown-parser' + +function parseWithDeadline(input: string) { + // A synchronous parser loop must fail without hanging the test worker. + return runInNewContext('parse(input)', { parse: parseMobileMarkdown, input }, { timeout: 250 }) +} + +describe('mobile Markdown parser progress', () => { + it.each(['```c++', '```c#', '``` ts', '```ts title="file.ts"', '````', '```!'])( + 'retains unsupported fence %s as paragraph text', + (fence) => { + expect(parseWithDeadline(fence)).toEqual([{ type: 'paragraph', text: fence }]) + expect(parseWithDeadline(`before\n${fence}\nafter`)).toEqual([ + { type: 'paragraph', text: `before\n${fence}\nafter` } + ]) + } + ) + + it.each(['# ', '## ', '###### ', '#\t'])('consumes incomplete heading %s', (heading) => { + expect(parseWithDeadline(`${heading}\nnext`)).toEqual([ + { type: 'paragraph', text: `${heading}\nnext` } + ]) + }) + + it('handles unsupported fences through the production preview normalization', () => { + const input = normalizeMobileMarkdownPreviewHtml('```c++\nint x = 1;') + expect(parseWithDeadline(input)).toEqual([{ type: 'paragraph', text: input }]) + }) + + it('recognizes a supported fence after unsupported fence text', () => { + expect(parseWithDeadline('```c++\n```ts\nconst x = 1\n```')).toEqual([ + { type: 'paragraph', text: '```c++' }, + { type: 'code', text: 'const x = 1', language: 'ts', closed: true } + ]) + }) + + it('preserves supported fences and their streaming state', () => { + expect(parseWithDeadline('before\n```ts\nconst x = 1')).toEqual([ + { type: 'paragraph', text: 'before' }, + { type: 'code', text: 'const x = 1', language: 'ts', closed: false } + ]) + }) +}) diff --git a/mobile/src/components/mobile-markdown-parser.ts b/mobile/src/components/mobile-markdown-parser.ts index fa16b3b9310..2ee83236007 100644 --- a/mobile/src/components/mobile-markdown-parser.ts +++ b/mobile/src/components/mobile-markdown-parser.ts @@ -8,6 +8,9 @@ export type MobileMarkdownBlock = | { type: 'table'; headers: string[]; rows: string[][] } | { type: 'rule' } +const HEADING = /^(#{1,6})\s+(.+)$/ +const CODE_FENCE = /^```([A-Za-z0-9_-]+)?\s*$/ + function splitTableRow(line: string): string[] { return line .trim() @@ -34,7 +37,7 @@ export function parseMobileMarkdown(content: string): MobileMarkdownBlock[] { continue } - const fence = line.match(/^```([A-Za-z0-9_-]+)?\s*$/) + const fence = line.match(CODE_FENCE) if (fence) { index += 1 const code: string[] = [] @@ -80,7 +83,7 @@ export function parseMobileMarkdown(content: string): MobileMarkdownBlock[] { continue } - const heading = line.match(/^(#{1,6})\s+(.+)$/) + const heading = line.match(HEADING) if (heading) { blocks.push({ type: 'heading', level: heading[1]!.length, text: heading[2]!.trim() }) index += 1 @@ -121,8 +124,8 @@ export function parseMobileMarkdown(content: string): MobileMarkdownBlock[] { while ( index < lines.length && lines[index]?.trim() && - !(lines[index] ?? '').startsWith('```') && - !/^(#{1,6})\s+/.test(lines[index] ?? '') && + !CODE_FENCE.test(lines[index] ?? '') && + !HEADING.test(lines[index] ?? '') && !/^>\s?/.test(lines[index] ?? '') && !/^\s*(?:[-*+]|\d+[.)])\s+/.test(lines[index] ?? '') && !/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(lines[index] ?? '')