Skip to content

Commit 2b00bad

Browse files
Merge pull request #4806 from nesquena/stage/4800-stream-perf
Release v0.51.613 — eliminate O(DOM) streaming render freeze on long answers (#4800)
2 parents f04bd9f + c184c0c commit 2b00bad

5 files changed

Lines changed: 104 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@
33

44
## [Unreleased]
55

6+
## [v0.51.613] — 2026-06-23 — Release VT (eliminate O(DOM) streaming render freeze on long answers)
7+
8+
### Fixed
9+
10+
- **Long agent answers no longer progressively freeze the UI while streaming.** On answers of 30,000+ characters (5,000+ DOM nodes), every streamed token ran a full-DOM link-sanitization scan, so render cost grew with the answer size and the tab eventually froze (and could crash the renderer). Link URL-scheme validation now happens inline as each DOM node is created (via a wrapped streaming-markdown renderer), eliminating the per-token full-DOM rescan, and the streaming render now caches its parse result to skip redundant re-parsing. Dangerous URL schemes (`javascript:`, `data:`, `vbscript:`, etc.) are still rejected on links and images exactly as before, and the final settled render still runs the full sanitizer. Thanks @wlknight. (#4800)
11+
612
## [v0.51.612] — 2026-06-23 — Release VS (background-tab notifications + profile default workspace on fresh sessions)
713

814
### Fixed

static/messages.js

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3070,7 +3070,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
30703070
_smdWrittenLen=0;
30713071
_smdWrittenText='';
30723072
if(!window.smd){_smdParser=null;return;}
3073-
const baseRenderer=fade ? _streamFadeRenderer(el) : window.smd.default_renderer(el);
3073+
const baseRenderer=fade ? _streamFadeRenderer(el) : _safeSmdRenderer(el);
30743074
const renderer=_smdRendererWithoutUnderscoreEmphasis(baseRenderer);
30753075
_smdParser=window.smd.parser(renderer);
30763076
}
@@ -3141,9 +3141,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
31413141
try{window.smd.parser_write(_smdParser,delta);}catch(_){}
31423142
_smdWrittenLen=displayText.length;
31433143
_smdWrittenText=displayText;
3144-
// streaming-markdown does NOT sanitize URL schemes. The default live path
3145-
// scans after writes; fade mode blocks unsafe href/src in its renderer.set_attr.
3146-
if(assistantBody&&!fade){_sanitizeSmdLinks(assistantBody);}
3144+
// URL scheme safety is handled by the renderer's set_attr hook
3145+
// (_safeSmdRenderer or _streamFadeRenderer), applied inline as smd
3146+
// creates each DOM node — no post-hoc full-DOM scan needed.
31473147
_scheduleStreamingKatex();
31483148
}
31493149
// Allowed URL schemes for anchors and images rendered from agent-streamed markdown.
@@ -3314,6 +3314,36 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
33143314
};
33153315
return renderer;
33163316
}
3317+
// Safe renderer: wraps default_renderer with a set_attr hook that validates
3318+
// href/src URL schemes inline — no post-hoc DOM-wide querySelectorAll needed.
3319+
// Unlike _streamFadeRenderer, this does NOT wrap add_text, so smd adds new
3320+
// DOM nodes as plain text nodes (no animation spans). Used on the non-fade
3321+
// streaming path to eliminate _sanitizeSmdLinks(assistantBody) O(DOM) scans
3322+
// on every token event (#WebUI-perf).
3323+
function _safeSmdRenderer(el){
3324+
const renderer=window.smd.default_renderer(el);
3325+
const baseSetAttr=renderer.set_attr;
3326+
renderer.set_attr=(data,attr,value)=>{
3327+
const isHref=window.smd&&attr===window.smd.HREF;
3328+
const isSrc=window.smd&&attr===window.smd.SRC;
3329+
const safeUrl=isSrc?_SMD_SAFE_IMG_URL_RE:_SMD_SAFE_URL_RE;
3330+
if(isHref&&/^(file|workspace|session):\/\//i.test(String(value||''))){
3331+
baseSetAttr(data,attr,_smdLinkHref(value));
3332+
if(/^session:\/\//i.test(String(value||''))){
3333+
const node=data&&data.nodes&&data.nodes[data.index];
3334+
if(node&&node.classList) node.classList.add('session-link');
3335+
}
3336+
return;
3337+
}
3338+
if((isHref||isSrc)&&!safeUrl.test(String(value||''))){
3339+
const node=data&&data.nodes&&data.nodes[data.index];
3340+
if(node&&node.setAttribute) node.setAttribute('data-blocked-scheme','1');
3341+
return;
3342+
}
3343+
baseSetAttr(data,attr,value);
3344+
};
3345+
return renderer;
3346+
}
33173347
function _streamFadeWordCountOf(text){
33183348
const m=String(text||'').match(/\S+/g);
33193349
return m?m.length:0;
@@ -3784,7 +3814,19 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
37843814
}
37853815

37863816
let _lastRenderMs=0;
3787-
function _scheduleRender(){
3817+
// Parse-result cache: _scheduleRender can accept a pre-computed _parseStreamState()
3818+
// from the token event handler, avoiding a duplicate O(n) scan inside _doRender
3819+
// when the rAF fires before the next token arrives.
3820+
let _cachedParsed=null;
3821+
let _cachedParsedText='';
3822+
let _cachedParsedReasoning='';
3823+
function _scheduleRender(parsed){
3824+
// If caller provides a pre-computed parse result, cache it for _doRender.
3825+
if(parsed){
3826+
_cachedParsed=parsed;
3827+
_cachedParsedText=assistantText;
3828+
_cachedParsedReasoning=liveReasoningText;
3829+
}
37883830
if(_renderPending) return;
37893831
if(_streamFinalized) return; // Bug A: don't schedule new rAF after stream finalized
37903832
_renderPending=true;
@@ -3803,7 +3845,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
38033845
// Guard: a pending setTimeout+rAF can outlive stream finalization.
38043846
if(_streamFinalized) return;
38053847
_lastRenderMs=performance.now();
3806-
const parsed=_parseStreamState();
3848+
const parsed=_cachedParsed&&_cachedParsedText===assistantText&&_cachedParsedReasoning===liveReasoningText ? _cachedParsed : _parseStreamState();
3849+
_cachedParsed=null;
38073850
_renderLiveThinking(parsed);
38083851
if(assistantBody){
38093852
const displayText = segmentStart===0
@@ -3898,7 +3941,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
38983941
const parsed=_parseStreamState();
38993942
if(_freshSegment) appendThinking('', _liveThinkingPlacement());
39003943
if(String((parsed&&parsed.displayText)||'').trim()||assistantRow) ensureAssistantRow();
3901-
_scheduleRender();
3944+
_scheduleRender(parsed);
39023945
});
39033946

39043947
source.addEventListener('interim_assistant',e=>{

tests/test_stable_assistant_turn_anchor_registry.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1301,7 +1301,7 @@ def test_slice6_live_shadow_feed_wires_anchor_scene_for_visible_order_handoff():
13011301
assert f"_applyToAnchor('{event_name}'" in _event_listener_body(src, event_name)
13021302

13031303
token_body = _event_listener_body(src, "token")
1304-
assert "_scheduleRender();" in token_body
1304+
assert "_scheduleRender(" in token_body
13051305
assert "function _upsertAnchorProcessProse" in src
13061306
assert "_upsertAnchorProcessProse(displayText" in src
13071307
reasoning_body = _event_listener_body(src, "reasoning")

tests/test_streaming_markdown.py

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -197,10 +197,18 @@ def test_smd_new_parser_resets_written_len(self):
197197
"_smdWrittenLen=0" in fn or "_smdWrittenLen = 0" in fn
198198
), "_smdNewParser must reset _smdWrittenLen to 0"
199199

200-
def test_smd_new_parser_calls_default_renderer(self):
200+
def test_smd_new_parser_calls_safe_renderer(self):
201201
fn = extract_fn(MESSAGES_JS, "_smdNewParser")
202-
assert fn and "default_renderer" in fn, (
203-
"_smdNewParser must call smd.default_renderer() to create a renderer"
202+
assert fn and (
203+
"_safeSmdRenderer(" in fn or "_streamFadeRenderer(" in fn
204+
), (
205+
"_smdNewParser must use _safeSmdRenderer or _streamFadeRenderer "
206+
"so URL scheme safety is applied inline via set_attr hook"
207+
)
208+
# Verify _safeSmdRenderer itself uses smd.default_renderer internally
209+
safefn = extract_fn(MESSAGES_JS, "_safeSmdRenderer")
210+
assert safefn and "default_renderer" in safefn, (
211+
"_safeSmdRenderer must call smd.default_renderer() to create the base renderer"
204212
)
205213

206214
def test_smd_new_parser_calls_parser(self):
@@ -706,16 +714,41 @@ def test_file_anchor_rewrite_helper_exists(self):
706714
assert "_smdFileHref" in MESSAGES_JS
707715
assert "api/media?path=" in MESSAGES_JS
708716

709-
def test_sanitize_called_after_smd_write(self):
710-
# _smdWrite must invoke _sanitizeSmdLinks on assistantBody after feeding the parser,
711-
# so anchors/images created mid-stream get their javascript:/data:/vbscript:
712-
# hrefs/srcs stripped before the user can click them.
713-
fn = extract_fn(MESSAGES_JS, "_smdWrite")
714-
assert fn, "_smdWrite function not found"
715-
assert "_sanitizeSmdLinks" in fn, (
716-
"_smdWrite must call _sanitizeSmdLinks(assistantBody) after parser_write "
717-
"so unsafe URL schemes are stripped from newly-added anchors/images "
718-
"before the user can click them"
717+
def test_url_safety_via_renderer_set_attr(self):
718+
# URL scheme safety is now enforced inline by the renderer's set_attr
719+
# hook (_safeSmdRenderer or _streamFadeRenderer) as smd creates each
720+
# DOM node, eliminating the post-hoc _sanitizeSmdLinks O(DOM) scan
721+
# per token that caused progressive streaming freeze on long answers.
722+
safefn = extract_fn(MESSAGES_JS, "_safeSmdRenderer")
723+
assert safefn, "_safeSmdRenderer must exist for URL safety on the non-fade path"
724+
assert "set_attr" in safefn, (
725+
"_safeSmdRenderer must override set_attr to validate href/src inline"
726+
)
727+
assert "_SMD_SAFE_URL_RE" in safefn, (
728+
"_safeSmdRenderer set_attr must use _SMD_SAFE_URL_RE for href safety"
729+
)
730+
assert "_SMD_SAFE_IMG_URL_RE" in safefn, (
731+
"_safeSmdRenderer set_attr must use _SMD_SAFE_IMG_URL_RE for src safety"
732+
)
733+
assert "data-blocked-scheme" in safefn, (
734+
"_safeSmdRenderer set_attr must set data-blocked-scheme on unsafe URLs"
735+
)
736+
# _safeSmdRenderer algorithm must be the same as the proven
737+
# _streamFadeRenderer set_attr (fade path has been in production).
738+
fadefn = extract_fn(MESSAGES_JS, "_streamFadeRenderer")
739+
assert fadefn and "set_attr" in fadefn, "_streamFadeRenderer must also have set_attr"
740+
# Both renderers go through _smdRendererWithoutUnderscoreEmphasis so
741+
# the set_attr hook is part of the final parser — verify the call chain.
742+
newparser = extract_fn(MESSAGES_JS, "_smdNewParser")
743+
assert newparser and "fade ? _streamFadeRenderer(el) : _safeSmdRenderer(el)" in newparser, (
744+
"_smdNewParser must route non-fade to _safeSmdRenderer and fade to _streamFadeRenderer"
745+
)
746+
# _sanitizeSmdLinks is still defined and used at parser_end as a final
747+
# safety net — not removed, just no longer called on every token.
748+
assert "_sanitizeSmdLinks" in MESSAGES_JS, "_sanitizeSmdLinks must still exist"
749+
endfn = extract_fn(MESSAGES_JS, "_smdEndParser")
750+
assert endfn and "_sanitizeSmdLinks" in endfn, (
751+
"_smdEndParser must still call _sanitizeSmdLinks as a final safety net"
719752
)
720753

721754
def test_sanitize_called_at_parser_end(self):

tests/test_streaming_race_fix.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def test_pending_raf_handle_declared(self):
4242

4343
def test_schedule_render_guards_on_stream_finalized(self):
4444
src = read('static/messages.js')
45-
m = re.search(r'function _scheduleRender\(\)\{.*?\n \}', src, re.DOTALL)
45+
m = re.search(r'function _scheduleRender\([^)]*\)\{.*?\n \}', src, re.DOTALL)
4646
assert m, "_scheduleRender not found"
4747
fn = m.group(0)
4848
assert '_streamFinalized' in fn, (

0 commit comments

Comments
 (0)