perf(#4346): recycle stable DOM nodes during virtual-scroll measurement re-renders
# Conflicts: # static/ui.js # tests/test_issue4346_vscroll_footer_jitter.py
This commit is contained in:
1 parent
fa3d612a4e
commit
9c364d8498
2 files changed
+97
-11
No files matched your search
+45
-11
@@ -436,6 +436,8 @@ let _messageVirtualMeasurementRetryCount=0;
|
||||
let _messageVirtualScrollActive=false;
|
||||
let _messageVirtualScrollSettleTimer=0;
|
||||
let _messageVirtualDeferredMeasurement=null;
|
||||
let _msgNodeRecycleEnabled=false;
|
||||
const _recycleStash=new Map();
|
||||
function _markMessageVirtualScrollActive(){
|
||||
_messageVirtualScrollActive=true;
|
||||
clearTimeout(_messageVirtualScrollSettleTimer);
|
||||
@@ -965,7 +967,9 @@ function _scheduleMessageVirtualizedRender(force){
|
||||
const liveWindow=_currentMessageVirtualWindow(liveVisWithIdx,_messageVirtualKeepTailCount());
|
||||
const liveKey=_messageVirtualWindowKeyFor(liveWindow);
|
||||
if(!force&&liveKey===_messageVirtualWindowKey) return;
|
||||
_compensateScrollForMeasurementDelta(()=>{ renderMessages({ preserveScroll:true }); });
|
||||
_msgNodeRecycleEnabled=true;
|
||||
try{ _compensateScrollForMeasurementDelta(()=>{ renderMessages({ preserveScroll:true }); }); }
|
||||
finally{ _msgNodeRecycleEnabled=false; }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11114,6 +11118,14 @@ function renderMessages(options){
|
||||
S.session && typeof S.session.compression_anchor_summary==='string'
|
||||
) ? S.session.compression_anchor_summary.trim() : '';
|
||||
const worklogDetailDisclosureState=_captureWorklogDetailDisclosureState(inner);
|
||||
_recycleStash.clear();
|
||||
if(_msgNodeRecycleEnabled){
|
||||
for(const child of Array.from(inner.children)){
|
||||
if(!child.dataset||!child.dataset.msgIdx) continue;
|
||||
if(child.id==='liveAssistantTurn'||child.querySelector&&child.querySelector('#liveAssistantTurn')) continue;
|
||||
_recycleStash.set(Number(child.dataset.msgIdx), child);
|
||||
}
|
||||
}
|
||||
inner.innerHTML='';
|
||||
const compressionNode=compressionState?_compressionCardsNode(compressionState):null;
|
||||
const {message:referenceMessage, rawIdx:referenceMessageRawIdx}=_latestCompressionReferenceMessage(
|
||||
@@ -11381,22 +11393,43 @@ function renderMessages(options){
|
||||
|
||||
if(isUser){
|
||||
currentAssistantTurn=null;
|
||||
const row=document.createElement('div');
|
||||
row.className='msg-row';
|
||||
row.id=_userMessageDomId(rawIdx);
|
||||
row.dataset.msgIdx=rawIdx;
|
||||
row.dataset.sessionMsgIdx=_messageSessionIndexForRawIdx(rawIdx);
|
||||
row.dataset.messageAnchorKey=_messageViewportAnchorKeyForMessage(m);
|
||||
row.dataset.role='user';
|
||||
row.dataset.rawText=String(displayContent).trim();
|
||||
row.innerHTML=`${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`;
|
||||
let row=_msgNodeRecycleEnabled?_recycleStash.get(rawIdx):null;
|
||||
const newRawText=String(displayContent).trim();
|
||||
if(row){
|
||||
row.id=_userMessageDomId(rawIdx);
|
||||
row.dataset.msgIdx=rawIdx;
|
||||
row.dataset.sessionMsgIdx=_messageSessionIndexForRawIdx(rawIdx);
|
||||
row.dataset.messageAnchorKey=_messageViewportAnchorKeyForMessage(m);
|
||||
row.dataset.role='user';
|
||||
if(row.dataset.rawText!==newRawText){
|
||||
row.dataset.rawText=newRawText;
|
||||
row.innerHTML=`${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`;
|
||||
}
|
||||
}else{
|
||||
row=document.createElement('div');
|
||||
row.className='msg-row';
|
||||
row.id=_userMessageDomId(rawIdx);
|
||||
row.dataset.msgIdx=rawIdx;
|
||||
row.dataset.sessionMsgIdx=_messageSessionIndexForRawIdx(rawIdx);
|
||||
row.dataset.messageAnchorKey=_messageViewportAnchorKeyForMessage(m);
|
||||
row.dataset.role='user';
|
||||
row.dataset.rawText=newRawText;
|
||||
row.innerHTML=`${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`;
|
||||
}
|
||||
inner.appendChild(row);
|
||||
userRows.set(rawIdx, row);
|
||||
continue;
|
||||
}
|
||||
|
||||
if(!currentAssistantTurn){
|
||||
currentAssistantTurn=_createAssistantTurn(tsTitle, isTpsDisplayEnabled()?_formatTurnTps(m._turnTps):'');
|
||||
const recycled=_msgNodeRecycleEnabled?_recycleStash.get(rawIdx):null;
|
||||
if(recycled){
|
||||
const blocks=_assistantTurnBlocks(recycled);
|
||||
if(blocks) blocks.innerHTML='';
|
||||
currentAssistantTurn=recycled;
|
||||
}else{
|
||||
currentAssistantTurn=_createAssistantTurn(tsTitle, isTpsDisplayEnabled()?_formatTurnTps(m._turnTps):'');
|
||||
}
|
||||
inner.appendChild(currentAssistantTurn);
|
||||
}
|
||||
const seg=document.createElement('div');
|
||||
@@ -12273,6 +12306,7 @@ function renderMessages(options){
|
||||
}
|
||||
}
|
||||
_updateMessageVirtualMeasurements(renderVisWithIdx, renderVisibleIdxs, virtualWindow);
|
||||
_recycleStash.clear();
|
||||
}
|
||||
|
||||
function _toolDisplayName(tc){
|
||||
|
||||
@@ -51,3 +51,55 @@ def test_js_try_finally_guards_class_removal():
|
||||
assert remove_idx != -1, "missing classList.remove('vscroll-measuring')"
|
||||
assert try_idx < finally_idx < remove_idx, \
|
||||
"classList.remove must remain in the finally{} cleanup path"
|
||||
|
||||
|
||||
def test_js_recycle_flag_exists():
|
||||
"""ui.js declares the _msgNodeRecycleEnabled flag."""
|
||||
assert '_msgNodeRecycleEnabled' in JS
|
||||
|
||||
|
||||
def test_js_recycle_stash_exists():
|
||||
"""ui.js declares the _recycleStash Map."""
|
||||
assert '_recycleStash' in JS
|
||||
|
||||
|
||||
def test_js_recycle_flag_set_in_virtual_render():
|
||||
"""_scheduleMessageVirtualizedRender sets _msgNodeRecycleEnabled=true
|
||||
before the compensate call and clears it in finally."""
|
||||
fn_match = re.search(
|
||||
r'function _scheduleMessageVirtualizedRender\(force\)\{(.+?)^(?=function )',
|
||||
JS, re.DOTALL | re.MULTILINE
|
||||
)
|
||||
assert fn_match, "_scheduleMessageVirtualizedRender not found"
|
||||
body = fn_match.group(1)
|
||||
assert '_msgNodeRecycleEnabled=true' in body
|
||||
finally_match = re.search(r'finally\{([^}]*)\}', body)
|
||||
assert finally_match, "no finally block in _scheduleMessageVirtualizedRender"
|
||||
assert '_msgNodeRecycleEnabled=false' in finally_match.group(1)
|
||||
|
||||
|
||||
def test_js_stash_populated_before_wipe():
|
||||
"""The recycleStash population loop appears before innerHTML='' in renderMessages."""
|
||||
fn_match = re.search(
|
||||
r'function renderMessages\(options\)\{(.+?)^(?=function )',
|
||||
JS, re.DOTALL | re.MULTILINE
|
||||
)
|
||||
assert fn_match, "renderMessages not found"
|
||||
body = fn_match.group(1)
|
||||
stash_pos = body.find('_recycleStash.set(')
|
||||
wipe_pos = body.find("inner.innerHTML='';")
|
||||
assert stash_pos != -1, "_recycleStash.set not found in renderMessages"
|
||||
assert wipe_pos != -1, "innerHTML wipe not found in renderMessages"
|
||||
assert stash_pos < wipe_pos, "_recycleStash.set must appear before innerHTML=''"
|
||||
|
||||
|
||||
def test_js_user_row_checks_stash():
|
||||
"""The user-row creation block checks _recycleStash before createElement."""
|
||||
fn_match = re.search(
|
||||
r'function renderMessages\(options\)\{(.+?)^(?=function )',
|
||||
JS, re.DOTALL | re.MULTILINE
|
||||
)
|
||||
assert fn_match, "renderMessages not found"
|
||||
body = fn_match.group(1)
|
||||
assert '_recycleStash.get(rawIdx)' in body, \
|
||||
"user row block must check _recycleStash.get(rawIdx)"
|
||||
Reference in new issue
Block a user