Fix TimelinePanel re-renders on duplicate sync state events (#33268)

* Avoid TimelinePanel re-renders on duplicate sync state events

* A better solution avoiding to store the entire syncState
This commit is contained in:
rbondesson 2026-04-24 14:54:01 +02:00 committed by GitHub
parent 76b65b14de
commit 15699c557d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 51 additions and 8 deletions

View File

@ -24,7 +24,7 @@ import {
type MatrixClient,
type Relations,
type MatrixError,
type SyncState,
SyncState,
TimelineWindow,
Thread,
ThreadEvent,
@ -192,9 +192,6 @@ interface IState {
backPaginating: boolean;
forwardPaginating: boolean;
// cache of matrixClient.getSyncState() (but from the 'sync' event)
clientSyncState: SyncState | null;
// should the event tiles have twelve hour times
isTwelveHour: boolean;
@ -251,12 +248,17 @@ class TimelinePanel extends React.Component<IProps, IState> {
// A map of <callId, LegacyCallEventGrouper>
private callEventGroupers = new Map<string, LegacyCallEventGrouper>();
private initialReadMarkerId: string | null = null;
private syncImpliesForwardPaginating: boolean;
public constructor(props: IProps, context: React.ContextType<typeof RoomContext>) {
super(props, context);
debuglog("mounting");
this.syncImpliesForwardPaginating = TimelinePanel.isSyncForwardPaginating(
MatrixClientPeg.safeGet().getSyncState(),
);
// XXX: we could track RM per TimelineSet rather than per Room.
// but for now we just do it per room for simplicity.
if (this.props.manageReadMarkers) {
@ -278,7 +280,6 @@ class TimelinePanel extends React.Component<IProps, IState> {
readMarkerEventId: this.initialReadMarkerId,
backPaginating: false,
forwardPaginating: false,
clientSyncState: MatrixClientPeg.safeGet().getSyncState(),
isTwelveHour: SettingsStore.getValue("showTwelveHourTimestamps"),
alwaysShowTimestamps: SettingsStore.getValue("alwaysShowTimestamps"),
readMarkerInViewThresholdMs: SettingsStore.getValue("readMarkerInViewThresholdMs"),
@ -899,9 +900,17 @@ class TimelinePanel extends React.Component<IProps, IState> {
private onSync = (clientSyncState: SyncState, prevState: SyncState | null, data?: object): void => {
if (this.unmounted) return;
this.setState({ clientSyncState });
const nextSyncImpliesForwardPaginating = TimelinePanel.isSyncForwardPaginating(clientSyncState);
if (nextSyncImpliesForwardPaginating === this.syncImpliesForwardPaginating) return;
this.syncImpliesForwardPaginating = nextSyncImpliesForwardPaginating;
this.forceUpdate();
};
private static isSyncForwardPaginating(syncState: SyncState | null): boolean {
return syncState === SyncState.Prepared || syncState === SyncState.Catchup;
}
private readMarkerTimeout(readMarkerPosition: number | null): number {
return readMarkerPosition === 0
? (this.context?.readMarkerInViewThresholdMs ?? this.state.readMarkerInViewThresholdMs)
@ -1832,8 +1841,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
// If the state is PREPARED or CATCHUP, we're still waiting for the js-sdk to sync with
// the HS and fetch the latest events, so we are effectively forward paginating.
const forwardPaginating =
this.state.forwardPaginating || ["PREPARED", "CATCHUP"].includes(this.state.clientSyncState!);
const forwardPaginating = this.state.forwardPaginating || this.syncImpliesForwardPaginating;
const events = this.state.events;
return (
<MessagePanel

View File

@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details.
import { render, waitFor, screen, act, cleanup } from "jest-matrix-react";
import {
ClientEvent,
ReceiptType,
EventTimelineSet,
EventType,
@ -19,6 +20,7 @@ import {
RoomEvent,
RoomMember,
RoomState,
SyncState,
TimelineWindow,
EventTimeline,
FeatureSupport,
@ -483,6 +485,39 @@ describe("TimelinePanel", () => {
});
});
it("only re-renders when sync changes forward pagination state", async () => {
const [client, room, events] = setupTestData();
let timelinePanel: TimelinePanel | null = null;
render(
<TimelinePanel
{...getProps(room, events)}
ref={(ref) => {
timelinePanel = ref;
}}
/>,
clientAndSDKContextRenderOptions(client, sdkContext),
);
await flushPromises();
await waitFor(() => expect(timelinePanel).toBeTruthy());
const forceUpdateSpy = jest.spyOn(timelinePanel!, "forceUpdate");
await act(async () => {
client.emit(ClientEvent.Sync, SyncState.Syncing, SyncState.Syncing);
await flushPromises();
});
expect(forceUpdateSpy).not.toHaveBeenCalled();
await act(async () => {
client.emit(ClientEvent.Sync, SyncState.Prepared, SyncState.Syncing);
await flushPromises();
});
expect(forceUpdateSpy).toHaveBeenCalledTimes(1);
});
describe("onRoomTimeline", () => {
it("ignores events for other timelines", () => {
const [client, room, events] = setupTestData();