diag(mobile): instrument slide frame-timing + render cost + load-older scroll to find the real jank

Re-add /api/dbg + a frame-gap/long-task probe over the chat-open slide, the innerHTML render time, and
before/after scroll for load-older — to measure on-device where the jank actually is (main-thread block
vs compositor) instead of guessing. Temporary; removed once fixed.
Build marker -> 2026-07-19-batch122.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 00:00:28 +05:30
parent 529bc26cb5
commit d02d8976d3
2 changed files with 29 additions and 2 deletions
+22 -2
View File
@@ -1140,7 +1140,7 @@
</head> </head>
<body> <body>
<script src="/icons.js?v=6"></script> <script src="/icons.js?v=6"></script>
<script>window.__BUILD='2026-07-18-batch121';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD); <script>window.__BUILD='2026-07-19-batch122';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
// Emoji are rendered with the OS's own (colour) emoji font — instant, zero network. // Emoji are rendered with the OS's own (colour) emoji font — instant, zero network.
// //
// We used to run Twemoji over every emoji, which swapped each one for an <img> pulled INDIVIDUALLY from // We used to run Twemoji over every emoji, which swapped each one for an <img> pulled INDIVIDUALLY from
@@ -1150,6 +1150,22 @@
// existing call sites don't have to change. // existing call sites don't have to change.
function twemojify(_el){ /* native emoji: nothing to do */ }</script> function twemojify(_el){ /* native emoji: nothing to do */ }</script>
<script> <script>
// TEMP perf diagnostics → /api/dbg (server log). Remove once slide/scroll jank is fixed.
window.bzDbg=function(tag,extra){ try{ var p={tag:tag,build:window.__BUILD}; if(extra) for(var k in extra) p[k]=extra[k]; fetch('/api/dbg',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)}).catch(function(){}); }catch(e){} };
// Measures frame gaps + long tasks over a window (the chat-open slide). Big maxGap / long tasks == the
// main thread is blocked (jank); small gaps == the compositor is running the slide smoothly.
window.__perfProbe=function(label){ try{
var frames=[], last=performance.now(), start=last, longtasks=[], po=null;
try{ po=new PerformanceObserver(function(l){ l.getEntries().forEach(function(e){ longtasks.push(Math.round(e.duration)); }); }); po.observe({entryTypes:['longtask']}); }catch(_){}
(function tick(now){ frames.push(Math.round(now-last)); last=now;
if(now-start<650){ requestAnimationFrame(tick); }
else { if(po) try{po.disconnect();}catch(_){}
var max=0; for(var i=1;i<frames.length;i++) if(frames[i]>max) max=frames[i];
var over=frames.filter(function(d){return d>24;}).length;
window.bzDbg('slidePerf',{label:label||'', frames:frames.length, maxGapMs:max, jankFrames:over, longtasks:longtasks, gaps:frames.slice(0,40)}); }
})(last);
}catch(e){} };
<script>
// Native shell tag (iOS/Android). Safe-area insets are handled natively by the @capacitor-community/ // Native shell tag (iOS/Android). Safe-area insets are handled natively by the @capacitor-community/
// safe-area plugin (it makes env(safe-area-inset-*) report real values), so there's no JS status-bar // safe-area plugin (it makes env(safe-area-inset-*) report real values), so there's no JS status-bar
// juggling here — we only tag <html> so CSS can make small native-only tweaks if needed. // juggling here — we only tag <html> so CSS can make small native-only tweaks if needed.
@@ -1498,8 +1514,11 @@ async function loadOlder(){
const have=new Set(THREAD.map(m=>m.id)); const add=older.filter(m=>!have.has(m.id)); const have=new Set(THREAD.map(m=>m.id)); const add=older.filter(m=>!have.has(m.id));
if(add.length){ THREAD=add.concat(THREAD); THREAD_CACHE.set(kind+':'+id, THREAD.slice()); } if(add.length){ THREAD=add.concat(THREAD); THREAD_CACHE.set(kind+':'+id, THREAD.slice()); }
if(older.length<PAGE) _hasMoreOlder=false; if(older.length<PAGE) _hasMoreOlder=false;
if(window.__perfProbe) window.__perfProbe('older'); // TEMP: measure frame timing during the older-page render
const _beforeTop = box?box.scrollTop:0;
renderThread(true); // keep scroll (don't jump to bottom) renderThread(true); // keep scroll (don't jump to bottom)
_restore(); // put the anchor message back at its exact viewport position _restore(); // put the anchor message back at its exact viewport position
if(window.bzDbg && box) window.bzDbg('older',{added:add.length, beforeScrollTop:Math.round(_beforeTop), afterScrollTop:Math.round(box.scrollTop), scrollH:Math.round(box.scrollHeight)}); // TEMP: did the anchor hold?
if(box){ const imgs=[...box.querySelectorAll('.bubble img')].filter(im=>!im.complete); imgs.forEach(im=>im.addEventListener('load', _restore, {once:true})); setTimeout(()=>imgs.forEach(im=>{ try{ im.removeEventListener('load', _restore); }catch(_){} }), 3000); } // re-anchor as prepended images load (they'd shift it down) if(box){ const imgs=[...box.querySelectorAll('.bubble img')].filter(im=>!im.complete); imgs.forEach(im=>im.addEventListener('load', _restore, {once:true})); setTimeout(()=>imgs.forEach(im=>{ try{ im.removeEventListener('load', _restore); }catch(_){} }), 3000); } // re-anchor as prepended images load (they'd shift it down)
} else { _hasMoreOlder=false; } } else { _hasMoreOlder=false; }
_loadingOlder=false; _olderCd=Date.now()+500; // brief cooldown so it can't re-fire and fight the scroll _loadingOlder=false; _olderCd=Date.now()+500; // brief cooldown so it can't re-fire and fight the scroll
@@ -2700,7 +2719,7 @@ function renderThread(keepScroll){
if(!THREAD.length){ box.innerHTML='<div class="empty-thread">No messages yet — say hello 👋</div>'; return; } if(!THREAD.length){ box.innerHTML='<div class="empty-thread">No messages yet — say hello 👋</div>'; return; }
let html=''; let html='';
for(const m of THREAD){ const dk=dayKey(m.created_at); if(dk!==_lastDay){ html+='<div class="day-sep"><span>'+pEsc(dayLabel(m.created_at))+'</span></div>'; _lastDay=dk; } rendered.add(m.id); html+=bubbleHTML(m); } for(const m of THREAD){ const dk=dayKey(m.created_at); if(dk!==_lastDay){ html+='<div class="day-sep"><span>'+pEsc(dayLabel(m.created_at))+'</span></div>'; _lastDay=dk; } rendered.add(m.id); html+=bubbleHTML(m); }
box.innerHTML=html; twemojify(box); var _rt0=performance.now(); box.innerHTML=html; twemojify(box); if(window.bzDbg && !keepScroll) window.bzDbg('render',{n:THREAD.length, innerHtmlKb:Math.round(html.length/1024), renderMs:Math.round(performance.now()-_rt0)}); // TEMP: how long the sync DOM build costs
if(!keepScroll){ if(!keepScroll){
if(_convoRevealed){ if(_convoRevealed){
// #7: a later render during the SAME open (network arriving after the cache render) — just stay at // #7: a later render during the SAME open (network arriving after the cache render) — just stay at
@@ -2764,6 +2783,7 @@ function armOpenSlide(){
if(c) c.addEventListener('transitionend', _slideEndHandler); if(c) c.addEventListener('transitionend', _slideEndHandler);
requestAnimationFrame(()=>requestAnimationFrame(()=>{ requestAnimationFrame(()=>requestAnimationFrame(()=>{
if(window.__slideTok!==token) return; if(window.__slideTok!==token) return;
if(window.__perfProbe) window.__perfProbe('slide'); // TEMP: measure frame timing over the slide
b.classList.add('chat-anim'); // compositor now runs transform 100%->0 b.classList.add('chat-anim'); // compositor now runs transform 100%->0
window.__chatOpenT=setTimeout(done, 360); // fallback if transitionend is missed (300ms + margin) window.__chatOpenT=setTimeout(done, 360); // fallback if transitionend is missed (300ms + margin)
})); }));
+7
View File
@@ -1004,6 +1004,13 @@ try {
} catch (_) {} } catch (_) {}
route('GET', '/api/build', (req, res) => json(res, 200, { build: APP_BUILD })); route('GET', '/api/build', (req, res) => json(res, 200, { build: APP_BUILD }));
// TEMP perf/debug sink — the app POSTs slide frame-timing + scroll state so we diagnose jank from the
// server log (docker logs bizgaze-support) with real device numbers. Remove once the slide/scroll settle.
route('POST', '/api/dbg', async (req, res) => {
try { const b = await readBody(req); console.log('[MDBG]', JSON.stringify(b)); } catch (_) {}
json(res, 200, { ok: true });
});
// Mint a LiveKit join token for the signed-in user + a specific room (the 6-digit meeting code). // Mint a LiveKit join token for the signed-in user + a specific room (the 6-digit meeting code).
// The room-membership/host authorization already happens over the meeting WebSocket; this only // The room-membership/host authorization already happens over the meeting WebSocket; this only
// hands the client a media-plane credential scoped to that room and its own identity. // hands the client a media-plane credential scoped to that room and its own identity.