RocketChat / RocketChat/Rocket.Chat
Message list intermittently never scrolls to bottom after sending your own message, and is always late when it does
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 46.1k
- Forks
- 13.9k
- Avg merge
- 3d 3h
- Merged PRs (30d)
- 130
Description
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:
- 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.
100 consecutive sends, multi-instance workspace, idle:
| count | server response | first scroll | reached bottom | |
|---|---|---|---|---|
| scrolled | 84 | 136–332 ms | 315–577 ms | 315–594 ms |
| scrolled late | 1 | 242 ms | 1279 ms | 1382 ms |
| never scrolled | 15 | 140–190 ms | never | never |
Three things to note:
- Every one of the 15 failures had a fast server response (140–190 ms) and then nothing at all for 15 seconds.
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. - 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.
https://github.com/user-attachments/assets/5bf84955-19d9-49c0-8ed3-86a393e877bd
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 () => {
const N = 20, WAIT = 10000, TOL = 60;
const list = document.querySelector('.messages-list');
const ta = document.querySelector('textarea.js-input-message, textarea[name="msg"]');
if (!list || !ta) return console.error('Open a room first.');
const sleep = ms => new Promise(r => setTimeout(r, ms));
const dist = () => list.scrollHeight - list.clientHeight - list.scrollTop;
const setValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set;
// some deployments use fetch, others XHR - hook both
let rtt = null;
const isSend = u => String(u || '').indexOf('sendMessage') !== -1;
const P = XMLHttpRequest.prototype, open = P.open, send = P.send;
P.open = function (m, u) { this.__u = u; return open.apply(this, arguments); };
P.send = function () {
if (isSend(this.__u)) {
const t = performance.now();
this.addEventListener('loadend', () => { rtt = Math.round(performance.now() - t); });
}
return send.apply(this, arguments);
};
const origFetch = window.fetch;
window.fetch = function (input) {
const url = typeof input === 'string' ? input : input && input.url;
if (!isSend(url)) return origFetch.apply(this, arguments);
const t = performance.now();
return origFetch.apply(this, arguments).then(res => { rtt = Math.round(performance.now() - t); return res; });
};
const rows = [];
for (let i = 1; i <= N; i++) {
list.scrollTop = Math.max(0, list.scrollTop - 400);
await sleep(700);
rtt = null;
ta.focus();
setValue.call(ta, `scroll test ${i}`);
ta.dispatchEvent(new Event('input', { bubbles: true }));
await sleep(120);
const ev = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
Object.defineProperty(ev, 'keyCode', { get: () => 13 });
Object.defineProperty(ev, 'which', { get: () => 13 });
ta.dispatchEvent(ev);
const t0 = performance.now();
let scrolledAfter = 'NEVER';
while (performance.now() - t0 < WAIT) {
if (dist() <= TOL) { scrolledAfter = Math.round(performance.now() - t0); break; }
await sleep(50);
}
rows.push({ run: i, 'sendMessage ms': rtt === null ? 'n/a' : rtt, 'scrolled after ms': scrolledAfter });
await sleep(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:179
const isNew = !Messages.state.find((record) => record._id === msg._id && record.temp !== true);
await upsertMessage({ msg, subscription });
if (isNew) {
await clientCallbacks.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-56
await runOptimisticSendMessage(message);
await sdk.call('sendMessage', message, previewUrls);
// after the request is complete we can go ahead and mark as sent
Messages.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, soisNewis alwaystruefor them. - Once a send is missed, nothing recovers it. While scrolled up,
isAtBottom.currentis
false, 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 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 atempmessage 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 = truebeforescrollToIndex
(ThreadMessageList.tsx:142-146), so theResizeObserverinuseKeepAtBottomstill
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.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Read useHasNewMessages.ts, MessageList.tsx, LegacyRoomManager.ts, and the optimistic send flow in sendMessage.ts and chats/data.ts. Compare the main-list behavior with the fix in ThreadMessageList.tsx from #40956, then verify that locally appended own messages trigger a prompt bottom scroll on every send without waiting for the server response.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- frontend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 52/100