You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Since the message list was virtualized in 8.5.0 (#40105), the main message list has two related defects when you send your own message:
Intermittently it never scrolls at all. 15% of sends in our measurements below.
The message is delivered and rendered, the list just stays where it is - indefinitely.
When it does scroll, it is always late. The list does not react to the locally
appended message; it only moves after the server round-trip completes.
Both look like the same underlying problem. The thread panel had the equivalent defect and it was fixed in #40956; that fix was never ported to the main message list.
Steps to reproduce:
Open a room and scroll up so the bottom of the list is out of view.
Type a short message and send it.
Repeat 15 times.
Expected behavior:
Sending your own message scrolls the list to the bottom, promptly and every time.
Actual behavior:
A variable fraction of sends never scroll. The rest scroll only after the server responds.
Server Setup Information:
Version of Rocket.Chat Server: 8.6.1
License Type: Enterprise
Number of Users: 600+
Operating System: Linux 6.8.0 (x64)
Deployment Method: docker (self-install)
Number of Running Instances: 6
DB Replicaset Oplog: n/a - 8.6 hardcodes statistics.oplogEnabled = false
NodeJS Version: v22.22.3
MongoDB Version: 8.0.28
Client Setup Information
Desktop App or Browser Version: Reproduced on Chromium 151, Firefox 153; Reported many more
Operating System: Linux
Additional context
Measurements
We instrumented an 8.6.1 workspace from the browser console: read the MessageList props off the React fiber, patch the isAtBottom ref to log every write, patch scrollTop/scrollTo on the .messages-list viewport to log every scroll attempt, and hook the sendMessage request to record the server response time. Each run scrolls up 400px, sends test N, and then observes for 15 seconds - long enough that a merely slow scroll cannot be mistaken for a missing one.
Every one of the 15 failures had a fast server response (140–190 ms) and then nothing at all for 15 seconds. shouldJumpToBottom never became true and no scroll was ever attempted on the viewport. This is not a slow scroll, and it is not a virtua/layout problem - the trigger simply does not fire.
Failures come in streaks. Runs 72, 73 and 74 all failed in a row; the distance to the bottom accumulated 400px → 830px → 1260px → 1690px before run 75 recovered. This matches what users report: several messages in a row do not scroll, then it works again.
On the successful runs the first scroll consistently happens ~200 ms after the server response (response ~180 ms, first scroll ~410 ms). The list is waiting for the network.
The failure rate depends on how fast the server answers
Splitting the same 100 sends by the sendMessage response time:
sendMessage response
sends
never scrolled
< 200 ms
48
15 (31%)
≥ 200 ms
52
0 (0%)
Fisher's exact test, one-sided: p = 4.3e-6. Mean response time was 159 ms for the failures and 207 ms for the successes.
So the failure is timing-dependent and tracks how quickly the method call returns. We have not been able to establish why (see "What we could not explain" below).
A failing run in detail (t=0 is the start of the recording):
t(ms) event shouldJumpToBottom isAtBottom scrollTop dist-to-bottom
0.1 ARM false false 153 400
164.8 SENT false false 153 400
197.5 list resize false false 153 430 <- own message rendered
(nothing else)
And a successful one:
t(ms) event shouldJumpToBottom isAtBottom scrollTop dist
0 ARM false false 0 613
180.6 SENT false false 0 613
219.7 list resize false false 0 643 <- own message rendered
616.0 JS set scrollTop true false 0 643 <- virtua scrolls
625.6 isAtBottom := true true true 643 0
We also verified isLoadingMoreMessages and hasMoreNextMessages were false in every failing run, so neither guard in the scroll effect is involved.
Live Demonstration
15 runs:
Each run: scroll up 400px, send a message, then watch whether the list follows.
Every step is announced before it happens and reported after.
out2.mp4
Reproducing the measurement
Console snippet — sends 20 messages and reports the failures (click to expand)
Paste into the browser console with a room open. It scrolls up 400px before each send, so
it exercises the same path a user does.
(async()=>{constN=20,WAIT=10000,TOL=60;constlist=document.querySelector('.messages-list');constta=document.querySelector('textarea.js-input-message, textarea[name="msg"]');if(!list||!ta)returnconsole.error('Open a room first.');constsleep=ms=>newPromise(r=>setTimeout(r,ms));constdist=()=>list.scrollHeight-list.clientHeight-list.scrollTop;constsetValue=Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set;// some deployments use fetch, others XHR - hook bothletrtt=null;constisSend=u=>String(u||'').indexOf('sendMessage')!==-1;constP=XMLHttpRequest.prototype,open=P.open,send=P.send;P.open=function(m,u){this.__u=u;returnopen.apply(this,arguments);};P.send=function(){if(isSend(this.__u)){constt=performance.now();this.addEventListener('loadend',()=>{rtt=Math.round(performance.now()-t);});}returnsend.apply(this,arguments);};constorigFetch=window.fetch;window.fetch=function(input){consturl=typeofinput==='string' ? input : input&&input.url;if(!isSend(url))returnorigFetch.apply(this,arguments);constt=performance.now();returnorigFetch.apply(this,arguments).then(res=>{rtt=Math.round(performance.now()-t);returnres;});};constrows=[];for(leti=1;i<=N;i++){list.scrollTop=Math.max(0,list.scrollTop-400);awaitsleep(700);rtt=null;ta.focus();setValue.call(ta,`scroll test ${i}`);ta.dispatchEvent(newEvent('input',{bubbles: true}));awaitsleep(120);constev=newKeyboardEvent('keydown',{key: 'Enter',bubbles: true,cancelable: true});Object.defineProperty(ev,'keyCode',{get: ()=>13});Object.defineProperty(ev,'which',{get: ()=>13});ta.dispatchEvent(ev);constt0=performance.now();letscrolledAfter='NEVER';while(performance.now()-t0<WAIT){if(dist()<=TOL){scrolledAfter=Math.round(performance.now()-t0);break;}awaitsleep(50);}rows.push({run: i,'sendMessage ms': rtt===null ? 'n/a' : rtt,'scrolled after ms': scrolledAfter});awaitsleep(1200);}P.open=open;P.send=send;window.fetch=origFetch;console.table(rows);console.log(`${rows.filter(r=>r['scrolled after ms']==='NEVER').length}/${N} sends never scrolled to the bottom.`);})();
What we could pin down
For your own messages, shouldJumpToBottom is only ever set from the streamNewMessage
callback in useHasNewMessages:
handleComposerResize is the only other per-send setter, and it is dead code in 8.6.1: RoomBody.tsx:237 passes onResize down, but neither ComposerMessage.tsx nor MessageBox.tsx ever calls it. So that one callback is the entire path.
It is gated in the room-messages stream handler:
// apps/meteor/app/ui-utils/client/lib/LegacyRoomManager.ts:179constisNew=!Messages.state.find((record)=>record._id===msg._id&&record.temp!==true);awaitupsertMessage({ msg, subscription });if(isNew){awaitclientCallbacks.run('streamNewMessage',msg);// line 193}
isNew decides via the temp flag of the optimistic record. The message _id is generated client-side (apps/meteor/client/lib/chats/data.ts:27), the optimistic record is inserted
with temp: true (apps/meteor/app/lib/client/methods/sendMessage.ts:42), and the send flow
strips temp as soon as the method resolves:
// apps/meteor/client/lib/chats/flows/sendMessage.ts:49-56awaitrunOptimisticSendMessage(message);awaitsdk.call('sendMessage',message,previewUrls);// after the request is complete we can go ahead and mark as sentMessages.state.update((record)=>record._id===message._id&&record.temp===true,({temp: _, ...record})=>record,);
Two further properties we confirmed:
Only your own messages are affected. Messages from other users have no optimistic
record, so isNew is always true for them.
Once a send is missed, nothing recovers it. While scrolled up, isAtBottom.current is false, so the second branch at MessageList.tsx:189 cannot fire either.
What we could not explain
Our first hypothesis was a race: if the method result strips temp before the stream echo
reaches the handler, isNew is false and the callback is skipped. We measured this and it
does not hold up.
We hooked the WebSocket and the REST layer to timestamp both signals per message. On our
workspace the method result arrives before the stream echo on every send - 27 out of 27,
by 95–150 ms - including all the sends that scrolled correctly. If that ordering alone decided
it, no send would ever scroll.
Context for anyone digging further: in 8.x, method calls do not go over the WebSocket. apps/meteor/client/meteor/overrides/ddpOverREST.ts routes them through POST /api/v1/method.call/<method> and synthesizes the DDP updated/result locally from the
HTTP response. So the method result and the stream echo genuinely travel over two different
transports.
So the gate at LegacyRoomManager.ts:179 is the only place we can see that would skip the
callback, but we have not been able to show that it is what actually skips it. Something else
decides whether streamNewMessage runs. We are still investigating and will update here.
What is measured and not in doubt: the trigger does not fire, no scroll is ever attempted, and
the failure rate tracks the server response time.
Verification
To check whether the missing trigger is the whole story, we deployed a crude client-side
workaround that scrolls the list from the locally appended message instead of waiting for
the network echo. Same workspace, same test, 100 sends each:
without workaround
with workaround
sends that never scrolled
15/100
0/100
Not waiting for the server round-trip removes the failure mode entirely.
(Our workaround is a blunt "pin the viewport to the bottom for 1.6s" hack that fights
virtua's incremental measurement and ends up slower than the native path, so its timing
numbers are not meaningful. Only the elimination of the failures is.)
Suggested fix
Whatever suppresses the callback, the list should not depend on the network round-trip to
scroll to a message the client has already appended. #40956 solved exactly that for ThreadMessageList.tsx. The same two changes apply to MessageList.tsx:
Trigger on the local optimistic message (ThreadMessageList.tsx:127-135) — detect that
the last item is a temp message from the current user and call setShouldJumpToBottom(true). This addresses both the missing scrolls and the latency, and
it does not depend on knowing why the callback is skipped.
Set isAtBottom.current = true before scrollToIndex
(ThreadMessageList.tsx:142-146), so the ResizeObserver in useKeepAtBottom still
corrects the position if content grows between the call and virtua's rAF.
Related: #41410 (open) additionally changes the main list from scrollToIndex(lastItemIndex + 1, { align: 'center' }) to aligning to the list end, which
looks correct independently of this issue.
Relevant logs:
No server-side errors. The sendMessage method returns HTTP 200 in every failing case; the defect is entirely client-side.
Description:
Since the message list was virtualized in 8.5.0 (#40105), the main message list has two related defects when you send your own message:
The message is delivered and rendered, the list just stays where it is - indefinitely.
appended message; it only moves after the server round-trip completes.
Both look like the same underlying problem. The thread panel had the equivalent defect and it was fixed in #40956; that fix was never ported to the main message list.
Steps to reproduce:
Expected behavior:
Sending your own message scrolls the list to the bottom, promptly and every time.
Actual behavior:
A variable fraction of sends never scroll. The rest scroll only after the server responds.
Server Setup Information:
statistics.oplogEnabled = falseClient Setup Information
Additional context
Measurements
We instrumented an 8.6.1 workspace from the browser console: read the
MessageListprops off the React fiber, patch theisAtBottomref to log every write, patchscrollTop/scrollToon the.messages-listviewport to log every scroll attempt, and hook thesendMessagerequest to record the server response time. Each run scrolls up 400px, sendstest N, and then observes for 15 seconds - long enough that a merely slow scroll cannot be mistaken for a missing one.100 consecutive sends, multi-instance workspace, idle:
Three things to note:
shouldJumpToBottomnever becametrueand no scroll was ever attempted on the viewport. This is not a slow scroll, and it is not a virtua/layout problem - the trigger simply does not fire.The failure rate depends on how fast the server answers
Splitting the same 100 sends by the
sendMessageresponse time:sendMessageresponseFisher's exact test, one-sided: p = 4.3e-6. Mean response time was 159 ms for the failures and 207 ms for the successes.
So the failure is timing-dependent and tracks how quickly the method call returns. We have not been able to establish why (see "What we could not explain" below).
A failing run in detail (t=0 is the start of the recording):
And a successful one:
We also verified
isLoadingMoreMessagesandhasMoreNextMessageswerefalsein every failing run, so neither guard in the scroll effect is involved.Live Demonstration
15 runs:
out2.mp4
Reproducing the measurement
Console snippet — sends 20 messages and reports the failures (click to expand)
Paste into the browser console with a room open. It scrolls up 400px before each send, so
it exercises the same path a user does.
What we could pin down
For your own messages,
shouldJumpToBottomis only ever set from thestreamNewMessagecallback in
useHasNewMessages:https://github.com/RocketChat/Rocket.Chat/blob/8.6.1/apps/meteor/client/views/room/body/hooks/useHasNewMessages.ts#L44-L57
handleComposerResizeis the only other per-send setter, and it is dead code in 8.6.1:RoomBody.tsx:237passesonResizedown, but neitherComposerMessage.tsxnorMessageBox.tsxever calls it. So that one callback is the entire path.It is gated in the
room-messagesstream handler:isNewdecides via thetempflag of the optimistic record. The message_idis generatedclient-side (
apps/meteor/client/lib/chats/data.ts:27), the optimistic record is insertedwith
temp: true(apps/meteor/app/lib/client/methods/sendMessage.ts:42), and the send flowstrips
tempas soon as the method resolves:Two further properties we confirmed:
record, so
isNewis alwaystruefor them.isAtBottom.currentisfalse, so the second branch atMessageList.tsx:189cannot fire either.What we could not explain
Our first hypothesis was a race: if the method result strips
tempbefore the stream echoreaches the handler,
isNewisfalseand the callback is skipped. We measured this and itdoes not hold up.
We hooked the WebSocket and the REST layer to timestamp both signals per message. On our
workspace the method result arrives before the stream echo on every send - 27 out of 27,
by 95–150 ms - including all the sends that scrolled correctly. If that ordering alone decided
it, no send would ever scroll.
Context for anyone digging further: in 8.x, method calls do not go over the WebSocket.
apps/meteor/client/meteor/overrides/ddpOverREST.tsroutes them throughPOST /api/v1/method.call/<method>and synthesizes the DDPupdated/resultlocally from theHTTP response. So the method result and the stream echo genuinely travel over two different
transports.
So the gate at
LegacyRoomManager.ts:179is the only place we can see that would skip thecallback, but we have not been able to show that it is what actually skips it. Something else
decides whether
streamNewMessageruns. We are still investigating and will update here.What is measured and not in doubt: the trigger does not fire, no scroll is ever attempted, and
the failure rate tracks the server response time.
Verification
To check whether the missing trigger is the whole story, we deployed a crude client-side
workaround that scrolls the list from the locally appended message instead of waiting for
the network echo. Same workspace, same test, 100 sends each:
Not waiting for the server round-trip removes the failure mode entirely.
(Our workaround is a blunt "pin the viewport to the bottom for 1.6s" hack that fights
virtua's incremental measurement and ends up slower than the native path, so its timing
numbers are not meaningful. Only the elimination of the failures is.)
Suggested fix
Whatever suppresses the callback, the list should not depend on the network round-trip to
scroll to a message the client has already appended. #40956 solved exactly that for
ThreadMessageList.tsx. The same two changes apply toMessageList.tsx:ThreadMessageList.tsx:127-135) — detect thatthe last item is a
tempmessage from the current user and callsetShouldJumpToBottom(true). This addresses both the missing scrolls and the latency, andit does not depend on knowing why the callback is skipped.
isAtBottom.current = truebeforescrollToIndex(
ThreadMessageList.tsx:142-146), so theResizeObserverinuseKeepAtBottomstillcorrects the position if content grows between the call and virtua's rAF.
Related: #41410 (open) additionally changes the main list from
scrollToIndex(lastItemIndex + 1, { align: 'center' })to aligning to the list end, whichlooks correct independently of this issue.
Relevant logs:
No server-side errors. The
sendMessagemethod returns HTTP 200 in every failing case; the defect is entirely client-side.