fix(ai-chat): stop the reasoning-stream hang — parse markdown only when expanded (#302) #303
Reference in New Issue
Block a user
Delete Branch "fix/302-reasoning-parse-when-open"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Чинит #302: AI-чат виснет во время фазы «размышления» (reasoning-стрим). closes #302.
Причина.
reasoning-block.tsxмемоизировал рендер markdown по[trimmed], а reasoning-текст стримится и растёт с каждой троттленной дельтой (~20 Гц). Значит на КАЖДОЙ дельте пере-парсился ВЕСЬ растущий текст (marked+DOMPurify) — O(n²)-шторм, забивающий main-thread и морозящий чат. Хуже: блок свёрнут по умолчанию, т.е. весь этот тяжёлый парсинг шёл для НЕВИДИМОГО тела (html показывается только внутри<Collapse in={open}>).Фикс. Парсить markdown reasoning только пока блок раскрыт (
open): свёрнутый показывает дешёвый raw-text фолбэк и не парсит ничего; при раскрытии — один парс текущего текста (мгновенно по клику), дальше при стриминге в открытом виде обычный per-delta append, как у ответа.How verified
renderChatMarkdownНЕ вызывается пока блок свёрнут, и вызывается ровно один раз при раскрытии (пиннит фикс, не-вакуозен — до фикса парс шёл на каждый рендер).vitest reasoning-block— 5 passed;tsc— 0 по затронутым.Checklist
The reasoning block memoized its markdown render on [trimmed] alone, so as the reasoning text streamed in it re-parsed the whole, ever-growing text (marked + DOMPurify) on every throttled delta (~20Hz) — an O(n^2) CPU storm that pinned the main thread and froze the chat during a long "thinking" phase. Worse, the block is collapsed by default, so all that parsing was for a hidden body the user never sees (html is only shown inside <Collapse in={open}>). Gate the parse on `open`: collapsed shows the cheap raw-text fallback and does no markdown parsing; expanding parses the current text once (an instant user click), and further streaming while open is the normal per-delta append render, like the answer. Test: assert renderChatMarkdown is not called while collapsed and is called once on expand. closes #302 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Ревью — #303 (fix ai-chat: reasoning-stream hang — парсить markdown только раскрытым, closes #302), round 1, head
2d30ad1fa, base develop (merge-basee648771ab)Вердикт: PASS — фикс корректен и точен, все 9 аспектов LGTM. Do-list пуст. Готов к мержу.
Полный 9-аспектный веер (79 строк, 2 файла). Объективка запущена мной (apps/client, детач
2d30ad1f):tsc --noEmit→ 0;vitest run reasoning-block.test.tsx→ 5 passed.Подтверждено по коду
useMemoтеперь гейтит наopen—open && trimmed ? renderChatMarkdown(collapseBlankLines(trimmed), {}) : "", deps[open, trimmed](reasoning-block.tsx:46-50).open=useState(false)(дефолт свёрнут), драйвит<Collapse in={open}>. Свёрнутый (общий случай длинного «thinking»-стрима) → html="" →renderChatMarkdown(marked+DOMPurify) НЕ вызывается → O(n²)-шторм пере-парса скрытого растущего тела устранён. При раскрытии — один парс текущего текста; дальше per-delta пока открыт — как у ответа (MarkdownPartпарсит так же), т.е. известный pre-existing предел, не регресс #302.estimateTokens=len/4O(1),text.trim()O(n) нативный — не источник hang.Collapse in=false) — контент не потерян.renderChatMarkdownвсегда санитайзит на раскрытом пути; свёрнутый фолбэк<Text>{trimmed}</Text>— React-эскейпленный текст, неdangerouslySetInnerHTMLraw-markdown. Инъекций нет.renderChatMarkdownна шве (vi.mock), ассерт «НЕ вызван свёрнутым» + «вызван 1 раз при click-раскрытии». До-фикс мемо[trimmed]парсил на mount независимо от open →not.toHaveBeenCalled()краснел бы.getByRole("button")— единственная кнопка (тоггл). Coherence/simpl/conv/docs/arch — LGTM (arch: правильная ось оптимизации, когерентно со стримингом ответа).Do — apply these, then re-review
(нет)
⛔ DROP — кодеру НЕ делать · калибровочный лог (для оператора)
[below-threshold]low[test-coverage] тест рендерит один раз статически; сам #302-сценарий (rerender с растущим текстом ПОКА свёрнут → всё ещё 0 парсов) не прогнан последовательностью. Ядро-мутант (снять open-гейт) уже убит существующим ассертом; доп-тест — durability. Приемлемо к мержу.[style/linter]info[conventions]vi.mockmarkdown-модуля — полный inline-replace вместоvi.hoisted+importActualпаттерна 2 соседних тестов; безвредно (у модуля один runtime-экспорт). Опц. унификация.[note]info[documentation] коммент:45«per-delta append render» чуть свободно (открытый стрим — полный re-parse, не инкрементальный append), смягчено «like the answer». Не дефект.