Skip to content

Message list intermittently never scrolls to bottom after sending your own message, and is always late when it does #41746

Description

@ndo84bw

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:

  1. 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.
  2. 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:

  1. Open a room and scroll up so the bottom of the list is out of view.
  2. Type a short message and send it.
  3. 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. 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 () => {
  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:

https://github.com/RocketChat/Rocket.Chat/blob/8.6.1/apps/meteor/client/views/room/body/hooks/useHasNewMessages.ts#L44-L57

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, 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:

  1. 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.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions