From 529bc26cb5f0aed74f078013ac821bafa2caa5d1 Mon Sep 17 00:00:00 2001 From: sravan Date: Sat, 18 Jul 2026 23:49:38 +0530 Subject: [PATCH] perf(mobile): compositor 'render-then-arm' chat-open slide + robust load-older anchor; remove diagnostics Slide (workflow-verified): the jump was the main thread building the message DOM + scroll writes DURING the 300ms animation. Now: render off-screen first, then arm a transform-only slide on a promoted GPU layer (translate3d/contain:layout paint) with all innerHTML/scrollTop work deferred to transitionend (afterOpenSlide queue). armOpenSlide runs after the sync render; appendBubble defers live writes mid-slide. Load-older jump: anchor on the specific oldest-loaded message element (not scrollHeight math, which broke when prepended images loaded and shifted content) and re-anchor as those images load. Removed all /api/dbg diagnostics (client reporter + server route + spike/diag probes). Build marker -> 2026-07-18-batch121. Co-Authored-By: Claude Opus 4.8 --- server/public/home.html | 116 +++++++++++++++++++++++----------------- server/routes.js | 7 --- 2 files changed, 66 insertions(+), 57 deletions(-) diff --git a/server/public/home.html b/server/public/home.html index eb35cc9..73c88dc 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -1101,8 +1101,14 @@ /* width:100vw makes the sliding pane a DEFINITE width regardless of any ancestor's transient 0-width during the animation frame — so bubbles never collapse, the composer measures at the right width, and translateX(100%) travels a real screen width (a true slide, not a no-travel "pop"). */ - body.chat-opening .content{display:block!important;width:100vw;max-width:100vw;z-index:2;box-shadow:-12px 0 30px rgba(15,23,42,.18);will-change:transform;animation:bzContentIn .3s cubic-bezier(.32,.72,0,1);} - body.chat-opening .chatcol{display:flex!important;position:absolute;inset:0;z-index:0;animation:bzListParallaxOut .3s cubic-bezier(.32,.72,0,1);} + /* Compositor-only push (workflow-verified). Phase 1 (chat-opening): the pane is pinned OFF-SCREEN on + its own promoted GPU layer while the message DOM renders + scrolls to bottom. Phase 2 (.chat-anim, + added by JS on a double-rAF AFTER the render is laid out & painted): the compositor transitions + transform 100%→0 with the main thread idle → a true 60fps slide, not a jump. Dropped at transitionend. */ + body.chat-opening .content{display:block!important;width:100vw;max-width:100vw;z-index:2;box-shadow:-12px 0 30px rgba(15,23,42,.18);transform:translate3d(100%,0,0);will-change:transform;backface-visibility:hidden;-webkit-backface-visibility:hidden;isolation:isolate;contain:layout paint;} + body.chat-opening.chat-anim .content{transform:translate3d(0,0,0);transition:transform .3s cubic-bezier(.32,.72,0,1);} + body.chat-opening .chatcol{display:flex!important;position:absolute;inset:0;z-index:0;transform:translate3d(0,0,0);will-change:transform;backface-visibility:hidden;} + body.chat-opening.chat-anim .chatcol{transform:translate3d(-22%,0,0);filter:brightness(.97);transition:transform .3s cubic-bezier(.32,.72,0,1),filter .3s cubic-bezier(.32,.72,0,1);} /* CLOSE / drag-back: reveal the list underneath (parallax in) while the conversation slides off right (.content moved by an inline transform in showWelcome()/the edge-drag gesture). */ body.chat-closing .content, body.chat-dragging .content{display:block!important;z-index:2;box-shadow:-12px 0 30px rgba(15,23,42,.18);will-change:transform;} @@ -1134,7 +1140,7 @@ - window.__bzResetKb=function(){ setKb(0); }; // called on composer blur as a safety net (#1: keyboard closed but lift stayed, leaving empty space after reply-cancel) var KB=C.Plugins&&C.Plugins.Keyboard; if(KB && KB.addListener){ - KB.addListener('keyboardWillShow', function(info){ var raw=(info&&info.keyboardHeight)||0; var kb = raw>window.innerHeight ? raw/(window.devicePixelRatio||1) : raw; setKb(kb); if(window.bzDbg) bzDbg('kbShow',{raw:raw, applied:Math.round(kb)}); }); + KB.addListener('keyboardWillShow', function(info){ var raw=(info&&info.keyboardHeight)||0; var kb = raw>window.innerHeight ? raw/(window.devicePixelRatio||1) : raw; setKb(kb); }); KB.addListener('keyboardWillHide', function(){ setKb(0); }); KB.addListener('keyboardDidHide', function(){ setKb(0); }); // belt-and-suspenders: some close paths only fire Did* } }catch(_){} })(); -
Biz Connect
Loading…
@@ -1507,7 +1482,16 @@ async function loadOlder(){ if(_loadingOlder || !_hasMoreOlder || !selected || !THREAD.length) return; _loadingOlder=true; const kind=selected.kind, id=selected.id, before=THREAD[0].created_at; - const box=document.getElementById('msgs'); const prevH=box?box.scrollHeight:0, prevTop=box?box.scrollTop:0; + const box=document.getElementById('msgs'); + const _esc=(s)=>(window.CSS&&CSS.escape)?CSS.escape(String(s)):String(s); + // Anchor on the CURRENT oldest-loaded message: after we prepend older ones (and as THEIR images load), + // keep this exact message at the same viewport position, so scrolling up never jumps. (The old + // scrollHeight math jumped because unloaded prepended images weren't counted, then shifted on load.) + const anchorId = THREAD[0] && THREAD[0].id; + const _boxTop = box ? box.getBoundingClientRect().top : 0; + const _aEl = (box&&anchorId) ? box.querySelector('.bubble[data-id="'+_esc(anchorId)+'"]') : null; + const _aTop = _aEl ? (_aEl.getBoundingClientRect().top - _boxTop) : 0; + const _restore=()=>{ if(!box||!anchorId) return; const el=box.querySelector('.bubble[data-id="'+_esc(anchorId)+'"]'); if(el){ const cur=el.getBoundingClientRect().top - box.getBoundingClientRect().top; box.scrollTop += (cur - _aTop); } }; let older=null; try{ const r=await fetch(threadUrl(kind,id,before)); if(r.ok) older=await r.json(); }catch(_){} if(!selected||selected.kind!==kind||selected.id!==id){ _loadingOlder=false; return; } if(Array.isArray(older) && older.length){ @@ -1515,7 +1499,8 @@ async function loadOlder(){ if(add.length){ THREAD=add.concat(THREAD); THREAD_CACHE.set(kind+':'+id, THREAD.slice()); } if(older.length!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; } _loadingOlder=false; _olderCd=Date.now()+500; // brief cooldown so it can't re-fire and fight the scroll } @@ -2733,6 +2718,7 @@ 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 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 @@ -2754,6 +2740,34 @@ let _openUnread=0; // set by selectChat (the unread count before it's reset) so function draftKey(kind,id){ return 'draft_'+((ME&&ME.id)||'')+'_'+kind+':'+id; } function getDraft(kind,id){ try{ return localStorage.getItem(draftKey(kind,id))||''; }catch(_){ return ''; } } function setDraft(kind,id,val){ try{ if(val&&val.trim()) localStorage.setItem(draftKey(kind,id), val); else localStorage.removeItem(draftKey(kind,id)); }catch(_){} } +// Compositor-only open slide (workflow-verified). armOpenSlide() runs AFTER openConvo's synchronous +// render: it pins the pane off-screen (chat-opening), lets a frame paint it, then adds .chat-anim on a +// double-rAF so the compositor runs the 300ms transform with the main thread idle. All scrollTop/innerHTML +// work (network re-render + pin schedule + live appends) is queued via afterOpenSlide and flushed only at +// transitionend. A token guards rapid chat-to-chat re-taps; a single stored handler avoids listener leaks. +let _slideQueue=[], _slideEndHandler=null; +function afterOpenSlide(fn){ if(!document.body.classList.contains('chat-opening')){ fn(); return; } _slideQueue.push(fn); } +function flushSlideQueue(){ const q=_slideQueue; _slideQueue=[]; for(const fn of q){ try{ fn(); }catch(_){} } } +function armOpenSlide(){ + const b=document.body, c=document.querySelector('.shell > .content'); + const token=(window.__slideTok=(window.__slideTok||0)+1); + if(c && _slideEndHandler){ c.removeEventListener('transitionend', _slideEndHandler); _slideEndHandler=null; } + clearTimeout(window.__chatOpenT); + b.classList.remove('chat-anim'); + b.classList.add('chat-opening'); // pane -> translate3d(100%), off-screen, own GPU layer + const done=()=>{ if(window.__slideTok!==token) return; + if(c && _slideEndHandler){ c.removeEventListener('transitionend', _slideEndHandler); _slideEndHandler=null; } + clearTimeout(window.__chatOpenT); + b.classList.remove('chat-opening','chat-anim'); + flushSlideQueue(); }; + _slideEndHandler=function(e){ if(e.target===c && e.propertyName==='transform') done(); }; + if(c) c.addEventListener('transitionend', _slideEndHandler); + requestAnimationFrame(()=>requestAnimationFrame(()=>{ + if(window.__slideTok!==token) return; + b.classList.add('chat-anim'); // compositor now runs transform 100%->0 + window.__chatOpenT=setTimeout(done, 360); // fallback if transitionend is missed (300ms + margin) + })); +} async function openConvo(kind,id){ const it=rowFor(kind,id)||{kind,id,name:'Conversation'}; const _unreadN=_openUnread; _openUnread=0; // consume the captured unread count (#3) @@ -2854,6 +2868,9 @@ async function openConvo(kind,id){ const ckey=kind+':'+id; if(THREAD_CACHE.has(ckey)){ THREAD=THREAD_CACHE.get(ckey).slice(); renderThread(); } else if(box){ box.innerHTML='
Loading messages…
'; } // branded loader instead of a blank pane on slow links + // Synchronous render done (shell + cache thread, already pinned to bottom) → arm the compositor slide now, + // so the transform animates a fully-laid-out, off-screen layer with the main thread idle. Fresh push only. + if(window.__freshOpen){ window.__freshOpen=false; armOpenSlide(); } if(kind==='group'){ try{ convoMembers=await fetch('/api/groups/members?group='+encodeURIComponent(id)).then(r=>r.json())||[]; }catch(_){ convoMembers=[]; } } if(!selected||selected.kind!==kind||selected.id!==id) return; wireMentions(); @@ -2873,7 +2890,7 @@ 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 - renderThread(); + afterOpenSlide(()=>{ if(selected&&selected.kind===kind&&selected.id===id) renderThread(); }); // defer the network re-render past the slide (no 40-bubble innerHTML into the compositing layer mid-transform) 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, // and show the "jump to newest" arrow so the user can return to the bottom. @@ -2892,15 +2909,12 @@ 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((b.scrollHeight-b.scrollTop-b.clientHeight)<1200) b.scrollTop=b.scrollHeight; }; - [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}); }); } - setTimeout(()=>{ try{ const b=document.getElementById('msgs'); if(b && window.bzDbg){ let tall={h:0,c:''}; b.querySelectorAll('.bubble,img,.att-img').forEach(el=>{ const h=el.getBoundingClientRect().height; if(h>tall.h) tall={h:Math.round(h), c:(el.className||el.tagName||'').toString().slice(0,36)}; }); bzDbg('convoOpen',{count:THREAD.length, scrollTop:Math.round(b.scrollTop), scrollH:Math.round(b.scrollHeight), clientH:Math.round(b.clientHeight), atBottom:(b.scrollHeight-b.scrollTop-b.clientHeight)<8, newsep:!!b.querySelector('.new-sep'), tallH:tall.h, tallC:tall.c}); } }catch(_){} }, 3600); - // Composer + transition diagnostics. - setTimeout(()=>{ try{ if(!window.bzDbg) return; var comp=document.querySelector('.composer'); var ta=document.getElementById('msgInput'); var content=document.querySelector('.shell > .content'); var cs=content?getComputedStyle(content):{}; - bzDbg('diag',{ compH: comp?Math.round(comp.getBoundingClientRect().height):-1, taH: ta?Math.round(ta.getBoundingClientRect().height):-1, taScrollH: ta?ta.scrollHeight:-1, taRows: ta?ta.rows:-1, taValLen: ta?(ta.value||'').length:-1, taNL: ta?(ta.value||'').split('\n').length:-1, taFs: ta?getComputedStyle(ta).fontSize:'', taLh: ta?getComputedStyle(ta).lineHeight:'', taInlineH: ta?(ta.style.height||''):'', chatOpening:document.body.classList.contains('chat-opening'), contentAnim: cs.animationName+' '+cs.animationDuration }); - }catch(_){} }, 160); - // Watch for the transient scrollHeight balloon; report what's tallest AND its tallest child + text. - if(window.bzDbg){ let _spk=0; const _watch=setInterval(()=>{ const b=document.getElementById('msgs'); if(!(selected&&selected.kind===kind&&selected.id===id)||!b){ clearInterval(_watch); return; } const per=b.scrollHeight/Math.max(1,THREAD.length); if(per>200 && _spk<2){ _spk++; let tall={h:0}; b.querySelectorAll('.bubble').forEach(el=>{ const h=el.getBoundingClientRect().height; if(h>tall.h){ let tc={h:0,d:''}; el.querySelectorAll('*').forEach(ch=>{ const chh=ch.getBoundingClientRect().height; if(chh>tc.h) tc={h:Math.round(chh), d:ch.tagName+'.'+(ch.className||'').toString().slice(0,16)}; }); tall={h:Math.round(h), c:(el.className||'').slice(0,18), txt:(el.textContent||'').trim().slice(0,44), kids:el.childElementCount, tallKid:tc.d+'='+tc.h}; } }); bzDbg('spike',{scrollH:Math.round(b.scrollHeight), per:Math.round(per), tallH:tall.h, tallC:tall.c, txt:tall.txt, kids:tall.kids, tallKid:tall.tallKid}); } }, 200); setTimeout(()=>clearInterval(_watch), 6000); } + // 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}); }); } + }); } // ---------- @mentions (group chat) ---------- let mentionItems=[], mentionIdx=0, mentionStart=-1; @@ -3074,10 +3088,12 @@ async function selectChat(kind,id){ if(_selMode) exitSelect(); // #1: leave forward-selection when changing chats selected={kind,id}; document.body.classList.add('chat-open'); // mobile: show the conversation pane - // Smooth native-style push: reveal the list underneath with a parallax while the conversation slides in. - if(window.matchMedia('(max-width:760px)').matches){ document.body.classList.add('chat-opening'); clearTimeout(window.__chatOpenT); window.__chatOpenT=setTimeout(()=>document.body.classList.remove('chat-opening'), 340); } + // DON'T start the slide here — openConvo builds the shell + renders the thread FIRST, then arms the + // compositor slide (armOpenSlide) once the pane is laid out & painted off-screen, so the transform runs + // with the main thread idle (smooth). Only for a fresh push on mobile. + window.__freshOpen = window.matchMedia('(max-width:760px)').matches; const it=rowFor(kind,id); _openUnread=(it&&it.unread)||0; if(it) it.unread=0; // capture before reset (#3: open at first unread) - renderChats(searchVal()); + renderChats(searchVal()); // lay out the LIST (the parallax layer) BEFORE the slide is armed updateRailUnread(); await openConvo(kind,id); } diff --git a/server/routes.js b/server/routes.js index 486fb3f..1999fee 100644 --- a/server/routes.js +++ b/server/routes.js @@ -1004,13 +1004,6 @@ try { } catch (_) {} route('GET', '/api/build', (req, res) => json(res, 200, { build: APP_BUILD })); -// TEMP mobile debug sink: the app POSTs its real runtime state here so we can diagnose device-only issues -// from the server log (docker logs bizgaze-support) without screenshots. Remove once mobile bugs 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). // 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.