fix: defer streaming KaTeX for pending equations

This commit is contained in:
ai-ag2026
2026-05-28 08:49:26 +02:00
parent 5528e2c579
commit 2ee249112a
5 changed files with 90 additions and 4 deletions

View File

@@ -3,6 +3,10 @@
## [Unreleased]
### Fixed
- Streaming KaTeX render passes now skip parser-owned equation placeholders that may still be receiving text, preventing long equations from being marked rendered before the final parser flush completes. (#2976)
## [v0.51.152] — 2026-05-28 — Release DX (stage-batch34 — single-PR optional gateway-backed browser chat)
### Added

View File

@@ -1067,7 +1067,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(_streamingKatexTimer) return;
_streamingKatexTimer=setTimeout(()=>{
_streamingKatexTimer=null;
if(assistantBody&&typeof renderKatexBlocks==='function') renderKatexBlocks(assistantBody);
if(assistantBody&&typeof renderKatexBlocks==='function') renderKatexBlocks(assistantBody,{streaming:true});
},150);
}
// Helper: feed new displayText delta to the smd parser.

View File

@@ -7754,8 +7754,25 @@ function renderMermaidBlocks(container){
let _katexLoading=false;
let _katexReady=false;
function renderKatexBlocks(container){
function _isStreamingEquationPending(el,root){
const tagName=(el&&el.tagName||'').toLowerCase();
if(tagName!=='equation-block'&&tagName!=='equation-inline') return false;
// streaming-markdown fills custom equation elements while the parser owns the
// open node. If the equation is currently the last descendant of the live
// assistant body, we cannot tell whether more TeX is still coming. Skip it
// during live debounce passes so a partial source is not permanently marked
// data-rendered before the final parser_end flush.
let node=el;
while(node&&node!==root){
if(node.nextSibling) return false;
node=node.parentNode;
}
return Boolean(node===root);
}
function renderKatexBlocks(container,options){
const root=container||document;
const streaming=Boolean(options&&options.streaming);
const blocks=root.querySelectorAll(
'.katex-block:not([data-rendered]),.katex-inline:not([data-rendered]),'+
'equation-block:not([data-rendered]),equation-inline:not([data-rendered])'
@@ -7779,6 +7796,7 @@ function renderKatexBlocks(container){
return;
}
blocks.forEach(el=>{
if(streaming&&_isStreamingEquationPending(el,root)) return;
el.dataset.rendered='true';
const src=el.textContent||'';
const tagName=(el.tagName||'').toLowerCase();

View File

@@ -0,0 +1,63 @@
"""Regression coverage for streaming KaTeX rendering (#2976)."""
from __future__ import annotations
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8")
def _extract_function(src: str, name: str) -> str:
marker = f"function {name}"
start = src.find(marker)
assert start >= 0, f"{name} not found"
brace = src.find("{", start)
assert brace >= 0, f"{name} body not found"
depth = 1
i = brace + 1
while i < len(src) and depth:
ch = src[i]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
i += 1
assert depth == 0, f"{name} body did not close"
return src[start:i]
def test_streaming_katex_scheduler_marks_live_pass_as_streaming():
"""The live 150ms KaTeX debounce must identify streaming passes.
Without the explicit streaming flag, renderKatexBlocks() cannot distinguish a
live parser-owned <equation-block> that is still being filled from a settled
DOM node, so it may mark partial math as data-rendered permanently.
"""
fn = _extract_function(MESSAGES_JS, "_scheduleStreamingKatex")
assert "renderKatexBlocks(assistantBody,{streaming:true})" in fn
def test_render_katex_blocks_skips_pending_streaming_equation_before_rendered_flag():
"""Streaming equation placeholders must be skipped before data-rendered.
The guard has to run before `el.dataset.rendered='true'`; otherwise a long
equation that is still receiving text becomes permanently ineligible for the
final complete KaTeX render.
"""
fn = _extract_function(UI_JS, "renderKatexBlocks")
assert "function _isStreamingEquationPending" in UI_JS
pending_idx = fn.find("_isStreamingEquationPending")
rendered_idx = fn.find("el.dataset.rendered='true'")
assert pending_idx != -1, "renderKatexBlocks must check pending streaming equations"
assert rendered_idx != -1, "renderKatexBlocks must still set data-rendered when rendering"
assert pending_idx < rendered_idx, "pending guard must run before data-rendered is set"
def test_final_katex_render_keeps_default_non_streaming_path():
"""Final renderKatexBlocks() calls must still render all math placeholders."""
fn = _extract_function(UI_JS, "renderKatexBlocks")
assert "const streaming=Boolean" in fn
assert "if(streaming&&_isStreamingEquationPending" in fn
done_fn = _extract_function(MESSAGES_JS, "_smdEndParser")
assert "renderKatexBlocks" not in done_fn, "done rendering remains in done handler after parser_end"

View File

@@ -11,7 +11,7 @@ def test_live_smd_writes_schedule_incremental_katex_rendering():
assert "let _streamingKatexTimer=null" in MESSAGES_JS
assert "function _scheduleStreamingKatex()" in MESSAGES_JS
assert "setTimeout(()=>{" in MESSAGES_JS
assert "renderKatexBlocks(assistantBody)" in MESSAGES_JS
assert "renderKatexBlocks(assistantBody,{streaming:true})" in MESSAGES_JS
smd_write_idx = MESSAGES_JS.index("function _smdWrite(displayText, fade=false){")
done_idx = MESSAGES_JS.index("source.addEventListener('done'")
@@ -28,8 +28,9 @@ def test_streaming_katex_timer_is_cleared_when_smd_parser_ends():
def test_katex_renderer_scans_live_and_settled_unrendered_nodes_under_container():
assert "function renderKatexBlocks(container){" in UI_JS
assert "function renderKatexBlocks(container,options){" in UI_JS
assert "const root=container||document;" in UI_JS
assert "const streaming=Boolean(options&&options.streaming);" in UI_JS
assert ".katex-block:not([data-rendered]),.katex-inline:not([data-rendered])," in UI_JS
assert "equation-block:not([data-rendered]),equation-inline:not([data-rendered])" in UI_JS
assert "const tagName=(el.tagName||'').toLowerCase();" in UI_JS