mobile: apply network content DURING the fade, not at landing (batch130)

Device log showed the slide motion itself is now clean 60fps (frames 0-15 all ~16ms in every
sample); the only remaining spike was at frame ~16 — AFTER the slide lands — from applying the
network thread result + pin at that instant (the "adjusts after it lands" feel). Since the open
is a compositor opacity/transform fade, main-thread work during it doesn't stall the animation
and is masked by the low opacity. So apply the reconcile/render + pins immediately when the
fetch returns (mid-fade) instead of deferring to slide-end. By the time the pane is fully opaque
the content is already settled. Removed the now-obsolete appendBubble mid-slide deferral.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 07:32:21 +05:30
parent 9bb74cffd2
commit 2a60c01268
+12 -10
View File
@@ -1146,7 +1146,7 @@
</head>
<body>
<script src="/icons.js?v=6"></script>
<script>window.__BUILD='2026-07-19-batch129';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
<script>window.__BUILD='2026-07-19-batch130';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.
//
// We used to run Twemoji over every emoji, which swapped each one for an <img> pulled INDIVIDUALLY from
@@ -2775,7 +2775,8 @@ function renderThread(keepScroll){
if(_selMode) box.querySelectorAll('.bubble').forEach(b=>{ if(_selIds.has(b.dataset.id)) b.classList.add('selected'); }); // #1: keep selection across re-render
}
function appendBubble(m){
if(document.body.classList.contains('chat-opening')){ afterOpenSlide(()=>appendBubble(m)); return; } // don't write into the compositing layer mid-slide; append the instant it ends
// (Open is now a compositor opacity/transform fade — a DOM write during it doesn't stall the animation and is
// masked by the low opacity, so we no longer defer appends to the end. That's what removed the settle-on-landing.)
if(rendered.has(m.id)) return; rendered.add(m.id);
const box=document.getElementById('msgs'); if(!box) return;
// Was the user already at the bottom? If they've scrolled UP to read history, an incoming message
@@ -2974,10 +2975,13 @@ async function openConvo(kind,id){
THREAD=msgs;
THREAD_CACHE.set(ckey, THREAD.slice());
_hasMoreOlder = msgs.length >= PAGE; _loadingOlder=false; // a full page back means there may be older history
// Apply the network result NOW (during the fade), not at the landing frame. The fade is compositor-driven,
// so this main-thread work runs smoothly under it and is masked by the low opacity — by the time the pane is
// fully opaque the content is already settled, so there's no "adjust after it lands" hitch.
if(_same){
afterOpenSlide(()=>{ if(!(selected&&selected.kind===kind&&selected.id===id)) return; const b=document.getElementById('msgs'); if(b && !b.querySelector('.new-sep')) b.scrollTop=b.scrollHeight; }); // identical to the cache render → no rebuild, just glue to the newest
if(selected&&selected.kind===kind&&selected.id===id){ const b=document.getElementById('msgs'); if(b && !b.querySelector('.new-sep')) b.scrollTop=b.scrollHeight; } // identical to the cache render → just glue to the newest
} else {
afterOpenSlide(()=>{ if(selected&&selected.kind===kind&&selected.id===id){ if(!reconcileOpen(_renderedOpen)) renderThread(); } }); // content changed → append the new tail cheaply; full re-render only if the page structure diverged
if(selected&&selected.kind===kind&&selected.id===id){ if(!reconcileOpen(_renderedOpen)) renderThread(); } // content changed → append the new tail cheaply; full re-render only if the page structure diverged
}
if(kind==='dm') syncDmRowTick(id); // #1: keep the sidebar tick consistent with the loaded thread
// #3: if there were unread messages, drop a "New messages" divider, scroll to the first unread,
@@ -2997,12 +3001,10 @@ async function openConvo(kind,id){
// keeps growing the thread after the first render. Re-pin on a schedule and on every image load, but
// only while still near the bottom, so a user who deliberately scrolled up isn't yanked back down.
const _pinNewest=()=>{ if(!(selected&&selected.kind===kind&&selected.id===id)) return; const b=document.getElementById('msgs'); if(!b||b.querySelector('.new-sep')) return; if(_forcePinOpen || (b.scrollHeight-b.scrollTop-b.clientHeight)<1200) b.scrollTop=b.scrollHeight; }; // _forcePinOpen: on a fresh open, force the newest into view no matter how far late images push it, until the user scrolls
// Pins run AFTER the slide (no scrollTop writes into the compositing layer mid-transform). They re-glue
// to the newest message as late images/day-separators grow the thread — this keeps "open at latest".
afterOpenSlide(()=>{
[0,120,320,600,1000,1600,2400,3400].forEach((d)=>setTimeout(_pinNewest, d));
const _pinBox=document.getElementById('msgs'); if(_pinBox){ _pinBox.querySelectorAll('img').forEach((im)=>{ if(!im.complete) im.addEventListener('load', _pinNewest, {once:true}); }); }
});
// Re-glue to the newest as late images/day-separators grow the thread. Spread over time (the compositor fade
// is unaffected by these main-thread pins), so "open at latest" holds while images finish loading.
[0,120,320,600,1000,1600,2400,3400].forEach((d)=>setTimeout(_pinNewest, d));
{ const _pinBox=document.getElementById('msgs'); if(_pinBox){ _pinBox.querySelectorAll('img').forEach((im)=>{ if(!im.complete) im.addEventListener('load', _pinNewest, {once:true}); }); } }
}
// ---------- @mentions (group chat) ----------
let mentionItems=[], mentionIdx=0, mentionStart=-1;