From 02ca306ffc85c134e52f047c384c9bc0c16ef614 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Wed, 13 May 2026 11:43:42 +0800 Subject: [PATCH] Consolidate session post-render processing --- CHANGELOG.md | 2 + static/sessions.js | 3 -- static/ui.js | 59 ++++++++++++++-------- tests/test_csv_table_rendering.py | 18 ++++--- tests/test_excalidraw_inline_embed.py | 20 +++++--- tests/test_issue347.py | 21 ++++---- tests/test_issue483_inline_diff_viewer.py | 11 ++-- tests/test_issue484_json_tree_viewer.py | 8 +-- tests/test_parallel_session_switch.py | 26 ++++------ tests/test_pdf_html_preview.py | 61 ++++++++++++----------- 10 files changed, 127 insertions(+), 102 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d737b24..7e185cc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/static/sessions.js b/static/sessions.js index 17ee262c..341d53fb 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -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; } } diff --git a/static/ui.js b/static/ui.js index d071d93e..2e9a7da5 100644 --- a/static/ui.js +++ b/static/ui.js @@ -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){ diff --git a/tests/test_csv_table_rendering.py b/tests/test_csv_table_rendering.py index a9c125ad..1d208c9f 100644 --- a/tests/test_csv_table_rendering.py +++ b/tests/test_csv_table_rendering.py @@ -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" diff --git a/tests/test_excalidraw_inline_embed.py b/tests/test_excalidraw_inline_embed.py index c407e108..677aac07 100644 --- a/tests/test_excalidraw_inline_embed.py +++ b/tests/test_excalidraw_inline_embed.py @@ -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(): diff --git a/tests/test_issue347.py b/tests/test_issue347.py index 4513139d..d51d8002 100644 --- a/tests/test_issue347.py +++ b/tests/test_issue347.py @@ -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();" diff --git a/tests/test_issue483_inline_diff_viewer.py b/tests/test_issue483_inline_diff_viewer.py index 2fd8573c..215d20ce 100644 --- a/tests/test_issue483_inline_diff_viewer.py +++ b/tests/test_issue483_inline_diff_viewer.py @@ -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.""" diff --git a/tests/test_issue484_json_tree_viewer.py b/tests/test_issue484_json_tree_viewer.py index 37007f51..789c8d17 100644 --- a/tests/test_issue484_json_tree_viewer.py +++ b/tests/test_issue484_json_tree_viewer.py @@ -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.""" diff --git a/tests/test_parallel_session_switch.py b/tests/test_parallel_session_switch.py index 566bd19d..75496ec8 100644 --- a/tests/test_parallel_session_switch.py +++ b/tests/test_parallel_session_switch.py @@ -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." ) diff --git a/tests/test_pdf_html_preview.py b/tests/test_pdf_html_preview.py index 7b97bf60..cb174b27 100644 --- a/tests/test_pdf_html_preview.py +++ b/tests/test_pdf_html_preview.py @@ -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 '&' 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 ────────────────────────────────────────────────────────────