Consolidate session post-render processing

This commit is contained in:
Frank Song
2026-05-13 11:43:42 +08:00
parent 9268f411d8
commit 02ca306ffc
10 changed files with 127 additions and 102 deletions
+2
View File
@@ -4,6 +4,8 @@
### Fixed
- **PR #2166** by @franksong2702 — Session switches no longer repeat the transcript post-render pass multiple times. The message renderer now schedules one `postProcessRenderedMessages(inner)` pass for both cached and freshly rebuilt transcripts, scopes inline preview/math/tree/code helpers to the rendered message container, and removes the extra idle-session `highlightCode()` call after `renderMessages()`. Local 8787 measurement on three large real sessions reduced post-render helper calls from `highlightCode=3x` / PDF+HTML+Mermaid+KaTeX `2x` to `1x` each, with static regression coverage pinning the consolidated pass.
- **PR #2136** by @LumenYoung — Stale stream writebacks no longer poison the active session transcript. `cancel_stream()` intentionally clears `active_stream_id` early so the UI can accept a follow-up turn while an old worker is unwinding — but the old worker could still return later from `run_conversation()` and persist its stale result over the newer transcript, causing visible transcript / turn journal / `state.db` to disagree (especially around cancel+retry on compressed continuations). Adds a single-line ownership check `_stream_writeback_is_current(session, stream_id)` (token equality against `session.active_stream_id`) and short-circuits both finalize paths: the success path in `_run_agent_streaming` and the cancel-handler path in `cancel_stream()`. When the stream no longer owns the writeback, both paths log `Skipping stale stream/cancel writeback` and return cleanly without persisting. 89-line regression suite in `tests/test_stale_stream_writeback.py`; companion updates to `tests/test_issue1361_cancel_data_loss.py` and `tests/test_sprint42.py` for the new return-without-persist behavior.
### Added
-3
View File
@@ -654,10 +654,7 @@ async function loadSession(sid){
updateQueueBadge(sid);
syncTopbar();renderMessages();
if(typeof resumeManualCompressionForSession==='function') resumeManualCompressionForSession(sid);
// Kick off loadDir first (issues network requests), then highlight code.
// The fetch is dispatched before the CPU-bound Prism pass begins.
const _dirP=loadDir('.');
highlightCode();
await _dirP;
}
}
+39 -20
View File
@@ -4851,8 +4851,7 @@ function renderMessages(options){
_wireMessageWindowLoadEarlierButton();
if(typeof _applySessionNavigationPrefs==='function') _applySessionNavigationPrefs();
_scrollAfterMessageRender(preserveScroll, scrollSnapshot);
requestAnimationFrame(()=>{highlightCode();addCopyButtons();loadDiffInline();loadCsvInline();loadExcalidrawInline();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();});
requestAnimationFrame(()=>{highlightCode();addCopyButtons();initTreeViews();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();});
requestAnimationFrame(()=>postProcessRenderedMessages(inner));
if(typeof _initMediaPlaybackObserver==='function') _initMediaPlaybackObserver();
if(typeof loadTodos==='function'&&document.getElementById('panelTodos')&&document.getElementById('panelTodos').classList.contains('active')){loadTodos();}
return;
@@ -5409,8 +5408,7 @@ function renderMessages(options){
// scrollIfPinned() respects _scrollPinned, so it's a no-op if user scrolled up.
_scrollAfterMessageRender(preserveScroll, scrollSnapshot);
// Apply syntax highlighting after DOM is built
requestAnimationFrame(()=>{highlightCode();addCopyButtons();loadDiffInline();loadCsvInline();loadExcalidrawInline();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();});
requestAnimationFrame(()=>{highlightCode();addCopyButtons();initTreeViews();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();});
requestAnimationFrame(()=>postProcessRenderedMessages(inner));
// Refresh todo panel if it's currently open
if(typeof loadTodos==='function' && document.getElementById('panelTodos') && document.getElementById('panelTodos').classList.contains('active')){
loadTodos();
@@ -5721,6 +5719,19 @@ async function regenerateResponse(btn) {
} catch(e) { setStatus(t('regen_failed') + e.message); }
}
function postProcessRenderedMessages(container) {
highlightCode(container);
addCopyButtons(container);
loadDiffInline(container);
loadCsvInline(container);
loadExcalidrawInline(container);
loadPdfInline(container);
loadHtmlInline(container);
renderMermaidBlocks(container);
renderKatexBlocks(container);
initTreeViews(container);
}
function highlightCode(container) {
// Apply Prism.js syntax highlighting to all code blocks in container (or whole messages area)
if(typeof Prism === 'undefined' || !Prism.highlightAllUnder) return;
@@ -5744,8 +5755,9 @@ function _loadJsyamlThen(cb){
document.head.appendChild(s);
}
function initTreeViews(){
document.querySelectorAll('.code-tree-wrap:not([data-tree-init])').forEach(wrap=>{
function initTreeViews(container){
const root=container||document;
root.querySelectorAll('.code-tree-wrap:not([data-tree-init])').forEach(wrap=>{
const rawText=wrap.dataset.raw;
const lang=wrap.dataset.lang;
let parsed=null;
@@ -5902,9 +5914,10 @@ function addCopyButtons(container){
let _mermaidLoading=false;
let _mermaidReady=false;
function loadDiffInline(){
function loadDiffInline(container){
const DIFF_MAX_SIZE=512*1024; // 512 KB cap for inline diff rendering
document.querySelectorAll('.diff-inline-load:not([data-loaded])').forEach(el=>{
const root=container||document;
root.querySelectorAll('.diff-inline-load:not([data-loaded])').forEach(el=>{
el.setAttribute('data-loaded','1');
const path=el.dataset.path;
fetch('api/media?path='+encodeURIComponent(path))
@@ -5929,9 +5942,10 @@ function loadDiffInline(){
});
}
function loadCsvInline(){
function loadCsvInline(container){
const CSV_MAX_SIZE=256*1024; // 256 KB cap for inline CSV rendering
document.querySelectorAll('.csv-inline-load:not([data-loaded])').forEach(el=>{
const root=container||document;
root.querySelectorAll('.csv-inline-load:not([data-loaded])').forEach(el=>{
el.setAttribute('data-loaded','1');
const path=el.dataset.path;
fetch('api/media?path='+encodeURIComponent(path))
@@ -5964,9 +5978,10 @@ function loadCsvInline(){
});
}
function loadExcalidrawInline(){
function loadExcalidrawInline(container){
const EXCALIDRAW_MAX_SIZE=512*1024; // 512 KB cap
document.querySelectorAll('.excalidraw-inline-load:not([data-loaded])').forEach(el=>{
const root=container||document;
root.querySelectorAll('.excalidraw-inline-load:not([data-loaded])').forEach(el=>{
el.setAttribute('data-loaded','1');
const path=el.dataset.path;
fetch('api/media?path='+encodeURIComponent(path))
@@ -6090,9 +6105,10 @@ function _renderExcalidrawCanvases(){
// the full buffer is received — ideally the server would enforce it before
// streaming (out of scope for this client-side PR).
let _pdfjsReady=false, _pdfjsLoading=false;
function loadPdfInline(){
function loadPdfInline(container){
const PDF_MAX_SIZE=4*1024*1024; // 4 MB cap for inline PDF preview
document.querySelectorAll('.pdf-preview-load:not([data-loaded])').forEach(el=>{
const root=container||document;
root.querySelectorAll('.pdf-preview-load:not([data-loaded])').forEach(el=>{
el.setAttribute('data-loaded','1');
const path=el.dataset.path;
const fname=path.split('/').pop()||path;
@@ -6164,9 +6180,10 @@ function loadPdfInline(){
}
// ── HTML inline preview (sandboxed iframe) ─────────────────────────────────
function loadHtmlInline(){
function loadHtmlInline(container){
const HTML_MAX_SIZE=256*1024; // 256 KB cap for inline HTML preview
document.querySelectorAll('.html-preview-load:not([data-loaded])').forEach(el=>{
const root=container||document;
root.querySelectorAll('.html-preview-load:not([data-loaded])').forEach(el=>{
el.setAttribute('data-loaded','1');
const path=el.dataset.path;
const fname=path.split('/').pop()||path;
@@ -6189,8 +6206,9 @@ function loadHtmlInline(){
});
}
function renderMermaidBlocks(){
const blocks=document.querySelectorAll('.mermaid-block:not([data-rendered])');
function renderMermaidBlocks(container){
const root=container||document;
const blocks=root.querySelectorAll('.mermaid-block:not([data-rendered])');
if(!blocks.length) return;
if(!_mermaidReady){
if(!_mermaidLoading){
@@ -6239,8 +6257,9 @@ function renderMermaidBlocks(){
let _katexLoading=false;
let _katexReady=false;
function renderKatexBlocks(){
const blocks=document.querySelectorAll('.katex-block:not([data-rendered]),.katex-inline:not([data-rendered])');
function renderKatexBlocks(container){
const root=container||document;
const blocks=root.querySelectorAll('.katex-block:not([data-rendered]),.katex-inline:not([data-rendered])');
if(!blocks.length) return;
if(!_katexReady){
if(!_katexLoading){
+10 -8
View File
@@ -53,14 +53,14 @@ def test_loadCsvInline_function():
"""Verify loadCsvInline lazy-load function exists."""
with open('static/ui.js') as f:
src = f.read()
assert 'function loadCsvInline()' in src, "Missing loadCsvInline function"
assert 'function loadCsvInline' in src, "Missing loadCsvInline function"
def test_csv_inline_max_size():
"""Verify CSV inline rendering has a size cap."""
with open('static/ui.js') as f:
src = f.read()
csv_section = src[src.find('function loadCsvInline()'):src.find('function loadCsvInline()') + 2000]
csv_section = src[src.find('function loadCsvInline'):src.find('function loadCsvInline') + 2000]
assert 'CSV_MAX_SIZE' in csv_section, "Should have CSV_MAX_SIZE constant"
assert 'csv_too_large' in csv_section, "Should use csv_too_large i18n for oversized files"
@@ -69,7 +69,7 @@ def test_csv_auto_detect_separator():
"""Verify CSV handler auto-detects separator."""
with open('static/ui.js') as f:
src = f.read()
csv_section = src[src.find('function loadCsvInline()'):src.find('function loadCsvInline()') + 2000]
csv_section = src[src.find('function loadCsvInline'):src.find('function loadCsvInline') + 2000]
assert 'separators' in csv_section, "Should have separator detection"
assert ';' in csv_section, "Should detect semicolon separator"
assert 'tab' in csv_section.lower() or '\\t' in csv_section, "Should detect tab separator"
@@ -86,24 +86,26 @@ def test_csv_error_handling():
"""Verify CSV error and empty data handling."""
with open('static/ui.js') as f:
src = f.read()
csv_section = src[src.find('function loadCsvInline()'):src.find('function loadCsvInline()') + 2500]
csv_section = src[src.find('function loadCsvInline'):src.find('function loadCsvInline') + 2500]
assert 'csv_error' in csv_section, "Should use csv_error i18n on fetch failure"
assert 'csv_no_data' in csv_section, "Should use csv_no_data i18n for insufficient data"
def test_csv_loadCsvInline_called_after_render():
"""Verify loadCsvInline is called in requestAnimationFrame after rendering."""
"""Verify loadCsvInline is called by the consolidated post-render pass."""
with open('static/ui.js') as f:
src = f.read()
assert src.count('loadCsvInline()') >= 2, \
"loadCsvInline should be called at least twice (initial render + cache restore)"
assert 'requestAnimationFrame(()=>postProcessRenderedMessages(inner))' in src
idx = src.find('function postProcessRenderedMessages')
body = src[idx:idx + 500]
assert 'loadCsvInline(container)' in body, "post-process should call loadCsvInline once per render"
def test_csv_line_ending_normalization():
"""Verify CSV handler normalizes line endings."""
with open('static/ui.js') as f:
src = f.read()
csv_section = src[src.find('function loadCsvInline()'):src.find('function loadCsvInline()') + 2000]
csv_section = src[src.find('function loadCsvInline'):src.find('function loadCsvInline') + 2000]
assert '\\r\\n' in csv_section, "Should handle \\r\\n line endings"
assert '\\r' in csv_section, "Should handle \\r line endings"
+12 -8
View File
@@ -22,14 +22,14 @@ def test_loadExcalidrawInline_function():
"""Verify loadExcalidrawInline lazy-load function exists."""
with open('static/ui.js') as f:
src = f.read()
assert 'function loadExcalidrawInline()' in src, "Missing loadExcalidrawInline function"
assert 'function loadExcalidrawInline' in src, "Missing loadExcalidrawInline function"
def test_excalidraw_json_validation():
"""Verify Excalidraw handler validates JSON format."""
with open('static/ui.js') as f:
src = f.read()
func = src[src.find('function loadExcalidrawInline()'):src.find('function loadExcalidrawInline()') + 2000]
func = src[src.find('function loadExcalidrawInline'):src.find('function loadExcalidrawInline') + 2000]
assert 'JSON.parse' in func, "Should parse JSON"
assert 'excalidraw_invalid' in func, "Should handle invalid format"
assert "data.type!=='excalidraw'" in func, "Should validate type field is 'excalidraw'"
@@ -39,7 +39,7 @@ def test_excalidraw_size_cap():
"""Verify Excalidraw inline rendering has a size cap."""
with open('static/ui.js') as f:
src = f.read()
func = src[src.find('function loadExcalidrawInline()'):src.find('function loadExcalidrawInline()') + 2000]
func = src[src.find('function loadExcalidrawInline'):src.find('function loadExcalidrawInline') + 2000]
assert 'EXCALIDRAW_MAX_SIZE' in func, "Should have EXCALIDRAW_MAX_SIZE constant"
assert 'excalidraw_too_large' in func, "Should use excalidraw_too_large i18n for oversized files"
@@ -48,7 +48,7 @@ def test_excalidraw_error_handling():
"""Verify Excalidraw error handling."""
with open('static/ui.js') as f:
src = f.read()
func = src[src.find('function loadExcalidrawInline()'):src.find('function loadExcalidrawInline()') + 3500]
func = src[src.find('function loadExcalidrawInline'):src.find('function loadExcalidrawInline') + 3500]
assert 'excalidraw_error' in func, "Should use excalidraw_error i18n on fetch failure"
@@ -114,17 +114,21 @@ def test_excalidraw_download_link():
"""Verify Excalidraw embed includes download link."""
with open('static/ui.js') as f:
src = f.read()
func = src[src.find('function loadExcalidrawInline()'):src.find('function loadExcalidrawInline()') + 2000]
func = src[src.find('function loadExcalidrawInline'):src.find('function loadExcalidrawInline') + 2000]
assert 'excalidraw-open-link' in func, "Should include open/download link"
assert 'excalidraw_download' in func, "Should use excalidraw_download i18n"
def test_excalidraw_called_after_render():
"""Verify loadExcalidrawInline is called after message rendering."""
"""Verify loadExcalidrawInline is called by the consolidated post-render pass."""
with open('static/ui.js') as f:
src = f.read()
assert src.count('loadExcalidrawInline()') >= 2, \
"loadExcalidrawInline should be called at least twice"
assert 'requestAnimationFrame(()=>postProcessRenderedMessages(inner))' in src
idx = src.find('function postProcessRenderedMessages')
body = src[idx:idx + 500]
assert 'loadExcalidrawInline(container)' in body, (
"post-process should call loadExcalidrawInline once per render"
)
def test_excalidraw_embed_wrap_structure():
+9 -12
View File
@@ -166,7 +166,7 @@ def test_data_katex_attribute_present():
def test_render_katex_blocks_function_exists():
"""renderKatexBlocks() function must exist in ui.js."""
assert 'function renderKatexBlocks()' in UI_JS, \
assert 'function renderKatexBlocks' in UI_JS, \
'renderKatexBlocks() function not found in ui.js'
@@ -202,21 +202,18 @@ def test_katex_throw_on_error_false():
def test_render_katex_blocks_wired_into_raf():
"""renderKatexBlocks() must be called in the same requestAnimationFrame as renderMermaidBlocks()."""
# Check that renderKatexBlocks appears somewhere near requestAnimationFrame
raf_idx = UI_JS.find('requestAnimationFrame')
# Find the rAF call that also contains renderKatexBlocks
has_katex_in_raf = any(
'renderKatexBlocks' in UI_JS[m.start():m.start()+200]
for m in re.finditer(r'requestAnimationFrame', UI_JS)
)
assert has_katex_in_raf, \
'renderKatexBlocks() not found in any requestAnimationFrame call — math will not render'
"""renderKatexBlocks() must run from the post-render requestAnimationFrame pass."""
raf_call = 'requestAnimationFrame(()=>postProcessRenderedMessages(inner))'
assert raf_call in UI_JS, 'post-render requestAnimationFrame pass not found'
idx = UI_JS.find('function postProcessRenderedMessages')
body = UI_JS[idx:idx + 500]
assert 'renderMermaidBlocks(container)' in body
assert 'renderKatexBlocks(container)' in body
def test_mermaid_render_failure_removes_temporary_error_dom():
"""Failed Mermaid renders must not leave Mermaid's body-level syntax-error SVG visible."""
fn_start = UI_JS.find('function renderMermaidBlocks()')
fn_start = UI_JS.find('function renderMermaidBlocks')
assert fn_start != -1, 'renderMermaidBlocks() function not found in ui.js'
fn = UI_JS[fn_start:fn_start + 2200]
cleanup = "const tmp=document.getElementById('d'+id);\n if(tmp) tmp.remove();"
+7 -4
View File
@@ -56,14 +56,17 @@ class TestMediaDiffInline:
"""loadDiffInline() function should be defined."""
with open("static/ui.js", "r", encoding="utf-8") as f:
content = f.read()
assert "function loadDiffInline()" in content
assert "function loadDiffInline" in content
def test_loadDiffInline_called_in_post_render(self):
"""loadDiffInline() should be called in post-render (after addCopyButtons)."""
"""loadDiffInline() should be called by the consolidated post-render pass."""
with open("static/ui.js", "r", encoding="utf-8") as f:
content = f.read()
count = content.count("loadDiffInline()")
assert count >= 2, f"loadDiffInline() called {count} times, expected >= 2 (cached + fresh render)"
assert "requestAnimationFrame(()=>postProcessRenderedMessages(inner))" in content
start = content.find("function postProcessRenderedMessages")
body = content[start:start + 500]
assert "addCopyButtons(container)" in body
assert "loadDiffInline(container)" in body
def test_diff_inline_error_class(self):
"""Should have error state class."""
+5 -3
View File
@@ -20,7 +20,7 @@ class TestTreeRenderer:
def test_initTreeViews_function_exists(self):
with open("static/ui.js", "r", encoding="utf-8") as f:
content = f.read()
assert "function initTreeViews()" in content
assert "function initTreeViews" in content
def test_buildTreeDOM_function_exists(self):
with open("static/ui.js", "r", encoding="utf-8") as f:
@@ -30,8 +30,10 @@ class TestTreeRenderer:
def test_initTreeViews_called_in_post_render(self):
with open("static/ui.js", "r", encoding="utf-8") as f:
content = f.read()
count = content.count("initTreeViews()")
assert count >= 2, f"initTreeViews() called {count} times, expected >= 2"
assert "requestAnimationFrame(()=>postProcessRenderedMessages(inner))" in content
start = content.find("function postProcessRenderedMessages")
body = content[start:start + 500]
assert "initTreeViews(container)" in body
def test_tree_handles_all_value_types(self):
"""_buildTreeDOM should handle null, boolean, number, string, array, object."""
+11 -15
View File
@@ -59,15 +59,14 @@ class TestLoadDirParallelPrefetch:
)
# ── 2. sessions.js: loadSession idle path overlaps loadDir and highlightCode
# ── 2. sessions.js: loadSession idle path avoids duplicate highlighting
class TestLoadSessionIdleOverlap:
"""The idle path in loadSession() must start loadDir() before running
highlightCode() so the network request is in-flight during the CPU-bound
Prism.js pass."""
"""The idle path in loadSession() should rely on renderMessages() for the
post-render transcript pass instead of running another Prism.js pass."""
def test_idle_path_starts_loaddir_before_highlightcode(self):
def test_idle_path_does_not_repeat_highlight_after_render_messages(self):
idle_marker = "S.busy=false"
positions = []
start = 0
@@ -81,25 +80,22 @@ class TestLoadSessionIdleOverlap:
found = False
for pos in positions:
block = SESSIONS_JS[pos : pos + 600]
has_highlight = "highlightCode()" in block
has_loaddir = "loadDir('.')" in block
if has_highlight and has_loaddir:
has_render = "renderMessages()" in block
if has_loaddir and has_render:
found = True
loaddir_idx = block.find("loadDir(")
highlight_idx = block.find("highlightCode()")
assert loaddir_idx < highlight_idx, (
"In the idle path, loadDir() should be started before "
"highlightCode() so the network request is dispatched first."
assert "highlightCode()" not in block, (
"The idle path should rely on renderMessages()'s consolidated "
"post-render pass instead of running a second highlight pass."
)
assert "await" in block and "_dirP" in block, (
"loadDir() result should be stored and awaited after "
"highlightCode() completes."
"loadDir() result should still be stored and awaited."
)
break
assert found, (
"Could not find the idle path in loadSession that calls both "
"loadDir and highlightCode."
"renderMessages and loadDir."
)
+32 -29
View File
@@ -104,37 +104,37 @@ class TestLoadPdfInlineFunction:
def test_function_exists(self):
ui = _read_js('ui.js')
assert 'function loadPdfInline()' in ui, 'loadPdfInline() function must exist'
assert 'function loadPdfInline' in ui, 'loadPdfInline() function must exist'
def test_selects_pdf_preview_load_elements(self):
ui = _read_js('ui.js')
idx = ui.find('function loadPdfInline()')
idx = ui.find('function loadPdfInline')
body = ui[idx:idx + 500]
assert 'pdf-preview-load' in body, 'Must query .pdf-preview-load elements'
assert 'data-loaded' in body, 'Must use data-loaded attribute to prevent double-processing'
def test_fetches_via_api_media(self):
ui = _read_js('ui.js')
idx = ui.find('function loadPdfInline()')
idx = ui.find('function loadPdfInline')
body = ui[idx:idx + 1500]
assert 'api/media?path=' in body, 'Must fetch PDF via api/media endpoint'
def test_has_size_cap(self):
ui = _read_js('ui.js')
idx = ui.find('function loadPdfInline()')
idx = ui.find('function loadPdfInline')
body = ui[idx:idx + 1500]
assert 'MAX_SIZE' in body or 'byteLength' in body, 'Must enforce a size cap on PDF files'
def test_fallback_on_error(self):
ui = _read_js('ui.js')
idx = ui.find('function loadPdfInline()')
idx = ui.find('function loadPdfInline')
body = ui[idx:idx + 3000]
assert 'pdf_error' in body, 'Must show error fallback on failure'
assert 'pdf_download' in body or 'download=' in body, 'Error fallback must include download link'
def test_lazy_loads_pdfjs_from_cdn(self):
ui = _read_js('ui.js')
idx = ui.find('function loadPdfInline()')
idx = ui.find('function loadPdfInline')
body = ui[idx:idx + 3000]
assert 'pdfjs' in body, 'Must lazy-load PDF.js from CDN'
@@ -149,44 +149,44 @@ class TestLoadHtmlInlineFunction:
def test_function_exists(self):
ui = _read_js('ui.js')
assert 'function loadHtmlInline()' in ui, 'loadHtmlInline() function must exist'
assert 'function loadHtmlInline' in ui, 'loadHtmlInline() function must exist'
def test_selects_html_preview_load_elements(self):
ui = _read_js('ui.js')
idx = ui.find('function loadHtmlInline()')
idx = ui.find('function loadHtmlInline')
body = ui[idx:idx + 500]
assert 'html-preview-load' in body, 'Must query .html-preview-load elements'
assert 'data-loaded' in body, 'Must use data-loaded attribute'
def test_fetches_via_api_media(self):
ui = _read_js('ui.js')
idx = ui.find('function loadHtmlInline()')
idx = ui.find('function loadHtmlInline')
body = ui[idx:idx + 1000]
assert 'api/media?path=' in body, 'Must fetch HTML via api/media endpoint'
def test_has_size_cap(self):
ui = _read_js('ui.js')
idx = ui.find('function loadHtmlInline()')
idx = ui.find('function loadHtmlInline')
body = ui[idx:idx + 1000]
assert 'MAX_SIZE' in body or 'html.length' in body, 'Must enforce a size cap on HTML files'
def test_fallback_on_error(self):
ui = _read_js('ui.js')
idx = ui.find('function loadHtmlInline()')
idx = ui.find('function loadHtmlInline')
body = ui[idx:idx + 2000]
assert 'html_error' in body, 'Must show error fallback on failure'
def test_uses_srcdoc_attribute(self):
"""Must use srcdoc (not src) for HTML content to keep it same-origin sandboxed."""
ui = _read_js('ui.js')
idx = ui.find('function loadHtmlInline()')
idx = ui.find('function loadHtmlInline')
body = ui[idx:idx + 1500]
assert 'srcdoc=' in body, 'Must use srcdoc attribute for inline HTML rendering'
def test_escapes_html_for_srcdoc(self):
"""HTML content must be escaped before embedding in srcdoc to prevent attribute injection."""
ui = _read_js('ui.js')
idx = ui.find('function loadHtmlInline()')
idx = ui.find('function loadHtmlInline')
body = ui[idx:idx + 1500]
# Must escape &, <, >, " to prevent breaking out of srcdoc attribute
assert '&amp;' in body or 'replace' in body, 'Must escape HTML entities for srcdoc'
@@ -195,31 +195,34 @@ class TestLoadHtmlInlineFunction:
# ── requestAnimationFrame integration ──────────────────────────────────────
class TestRAFIntegration:
"""Both lazy-load functions must be called in the requestAnimationFrame blocks."""
"""Lazy-load functions must be called by the consolidated post-render pass."""
def test_loadPdfInline_called_after_render(self):
ui = _read_js('ui.js')
raf_blocks = re.findall(r'requestAnimationFrame\(\(\)=>\{[^}]+\}\)', ui)
load_blocks = [b for b in raf_blocks if 'loadDiffInline' in b]
assert len(load_blocks) >= 2, 'Expected at least 2 rAF blocks with loadDiffInline'
for block in load_blocks:
assert 'loadPdfInline()' in block, 'loadPdfInline() must be called alongside loadDiffInline'
idx = ui.find('function postProcessRenderedMessages')
body = ui[idx:idx + 500]
assert 'loadDiffInline(container)' in body, 'post-process must call loadDiffInline'
assert 'loadPdfInline(container)' in body, 'post-process must call loadPdfInline alongside loadDiffInline'
def test_loadHtmlInline_called_after_render(self):
ui = _read_js('ui.js')
raf_blocks = re.findall(r'requestAnimationFrame\(\(\)=>\{[^}]+\}\)', ui)
load_blocks = [b for b in raf_blocks if 'loadDiffInline' in b]
for block in load_blocks:
assert 'loadHtmlInline()' in block, 'loadHtmlInline() must be called alongside loadDiffInline'
idx = ui.find('function postProcessRenderedMessages')
body = ui[idx:idx + 500]
assert 'loadDiffInline(container)' in body, 'post-process must call loadDiffInline'
assert 'loadHtmlInline(container)' in body, 'post-process must call loadHtmlInline alongside loadDiffInline'
def test_initTreeViews_blocks_also_call_loaders(self):
"""rAF blocks with initTreeViews (not loadDiffInline) must also call PDF/HTML loaders."""
"""Tree views and inline loaders must share the same post-process pass."""
ui = _read_js('ui.js')
raf_blocks = re.findall(r'requestAnimationFrame\(\(\)=>\{[^}]+\}\)', ui)
tree_blocks = [b for b in raf_blocks if 'initTreeViews' in b and 'loadDiffInline' not in b]
for block in tree_blocks:
assert 'loadPdfInline()' in block, 'initTreeViews rAF block must also call loadPdfInline'
assert 'loadHtmlInline()' in block, 'initTreeViews rAF block must also call loadHtmlInline'
idx = ui.find('function postProcessRenderedMessages')
body = ui[idx:idx + 500]
assert 'initTreeViews(container)' in body, 'post-process must initialize tree views'
assert 'loadPdfInline(container)' in body, 'post-process must also call loadPdfInline'
assert 'loadHtmlInline(container)' in body, 'post-process must also call loadHtmlInline'
def test_message_render_uses_single_post_process_raf(self):
ui = _read_js('ui.js')
assert ui.count('requestAnimationFrame(()=>postProcessRenderedMessages(inner))') == 2
# ── CSS classes ────────────────────────────────────────────────────────────