Skip to content

Commit ab89242

Browse files
authored
Fix thread edit aggregation race condition (#4980)
* test(thread): add regression tests for edit-race; ensure reaction aggregation idempotence - Edit race: add failing test when `Replace` aggregated pre-init - Reaction: ensure pre-init aggregation and dedup on replay - Strengthen assertions for ordering and content * fix(thread): apply edits after init; keep reactions pre-init; remove redundant aggregation - Defer Replace aggregation until thread initialised and event is in timeline - Aggregate Annotation pre-init to preserve reaction summaries - Rely on EventTimelineSet to aggregate post-insert - Fixes: element-hq/element-web#30617 * style: run prettier; docs: clarify reaction pre-init comment about counts
1 parent ed607c4 commit ab89242

File tree

2 files changed

+218
-4
lines changed

2 files changed

+218
-4
lines changed

spec/unit/models/thread.spec.ts

Lines changed: 210 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { FeatureSupport, Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../..
2222
import { makeThreadEvent, mkThread, populateThread } from "../../test-utils/thread";
2323
import { TestClient } from "../../TestClient";
2424
import { emitPromise, mkEdit, mkMessage, mkReaction, mock } from "../../test-utils/test-utils";
25-
import { Direction, EventStatus, EventType, MatrixEvent } from "../../../src";
25+
import { Direction, EventStatus, EventType, MatrixEvent, RelationType } from "../../../src";
2626
import { ReceiptType } from "../../../src/@types/read_receipts";
2727
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../test-utils/client";
2828
import { ReEmitter } from "../../../src/ReEmitter";
@@ -773,6 +773,215 @@ describe("Thread", () => {
773773
expect(lastEvent).toBe(message1);
774774
expect(firstEvent).toBe(message2);
775775
});
776+
777+
it("Edit events are properly aggregated in threads with server-side support", async () => {
778+
// This test reproduces the race condition bug from https://github.com/element-hq/element-web/issues/30617
779+
// The bug occurs when edits arrive while the thread is not initialized,
780+
// causing aggregation to fail because the target event isn't in the timeline yet
781+
782+
// Given a thread exists with server-side support enabled
783+
const myUserId = "@alice:example.org";
784+
const testClient = new TestClient(myUserId, "DEVICE", "ACCESS_TOKEN", undefined, {
785+
timelineSupport: false,
786+
});
787+
const client = testClient.client;
788+
client.supportsThreads = jest.fn().mockReturnValue(true);
789+
790+
const room = new Room("!room:z", client, myUserId, {
791+
pendingEventOrdering: PendingEventOrdering.Detached,
792+
});
793+
jest.spyOn(client, "getRoom").mockReturnValue(room);
794+
795+
// Create a root event
796+
const rootEvent = mkMessage({
797+
room: room.roomId,
798+
user: myUserId,
799+
msg: "Root message",
800+
event: true,
801+
});
802+
803+
// Create thread manually - starts with initialEventsFetched = false
804+
const thread = new Thread(rootEvent.getId()!, rootEvent, {
805+
room: room,
806+
client: client,
807+
pendingEventOrdering: PendingEventOrdering.Detached,
808+
});
809+
810+
// The thread is NOT initialized - this is the key to reproducing the bug!
811+
expect(thread.initialEventsFetched).toBe(false);
812+
813+
// Create a message that will be edited
814+
const originalMessage = mkMessage({
815+
room: room.roomId,
816+
user: myUserId,
817+
msg: "Original message in thread",
818+
relatesTo: {
819+
"rel_type": THREAD_RELATION_TYPE.name,
820+
"event_id": thread.id,
821+
"m.in_reply_to": {
822+
event_id: thread.id,
823+
},
824+
},
825+
event: true,
826+
});
827+
828+
// Create edit events BEFORE the original message is in the timeline
829+
const edit1 = mkEdit(originalMessage, client, myUserId, room.roomId, "Edit 1");
830+
const edit2 = mkEdit(originalMessage, client, myUserId, room.roomId, "Edit 2");
831+
const edit3 = mkEdit(originalMessage, client, myUserId, room.roomId, "Final edit");
832+
833+
// CRITICAL: Add edits while thread is NOT initialized
834+
// They will be queued in replayEvents and aggregation will be attempted but fail
835+
await thread.addEvent(edit1, false);
836+
837+
// Check the aggregation state after adding first edit
838+
// With our fix: edits should NOT be aggregated yet (thread not initialized)
839+
// Without fix: edits would be aggregated but fail to link to target
840+
const relationsAfterFirstEdit = thread.timelineSet.relations?.getChildEventsForEvent(
841+
originalMessage.getId()!,
842+
RelationType.Replace,
843+
EventType.RoomMessage,
844+
);
845+
846+
// With the fix, no aggregation happens yet (which is correct)
847+
// Without the fix, aggregation would happen but fail silently
848+
expect(relationsAfterFirstEdit).toBeUndefined();
849+
850+
// Add remaining edits
851+
await thread.addEvent(edit2, false);
852+
await thread.addEvent(edit3, false);
853+
854+
// Check that edits went to replayEvents
855+
expect(thread.replayEvents).toHaveLength(3);
856+
857+
// Now initialize the thread and add the original message
858+
thread.initialEventsFetched = true;
859+
860+
// Clear replayEvents and add the original message
861+
const replayEvents = [...(thread.replayEvents || [])];
862+
thread.replayEvents = [];
863+
864+
// Add original message first
865+
await thread.addEvent(originalMessage, false);
866+
867+
// At this point, the original message should NOT have the edits aggregated yet
868+
// because they were attempted when the target wasn't in timeline
869+
const replacingEventBeforeReplay = originalMessage.replacingEvent();
870+
// With the fix, edits should not be aggregated yet (pre-init)
871+
expect(replacingEventBeforeReplay).toBeNull();
872+
873+
// Then replay the edits
874+
for (const event of replayEvents) {
875+
await thread.addEvent(event, false);
876+
}
877+
878+
// After replay, check aggregation
879+
const replacingEvent = originalMessage.replacingEvent();
880+
881+
// This should now work because edits were re-aggregated when replayed
882+
expect(replacingEvent).toBe(edit3);
883+
884+
// The content should also be updated
885+
expect(originalMessage.getContent().body).toBe("Final edit");
886+
887+
// Relations for replaces should now exist and include all edits in order
888+
const replaceRels = thread.timelineSet.relations!.getChildEventsForEvent(
889+
originalMessage.getId()!,
890+
RelationType.Replace,
891+
EventType.RoomMessage,
892+
)!;
893+
const replaceIds = replaceRels.getRelations().map((e) => e.getId());
894+
expect(replaceIds).toHaveLength(3);
895+
expect(replaceIds[0]).toBe(edit1.getId());
896+
expect(replaceIds[1]).toBe(edit2.getId());
897+
expect(replaceIds[2]).toBe(edit3.getId());
898+
});
899+
900+
it("Reactions aggregate pre-init and remain idempotent on replay", async () => {
901+
const myUserId = "@alice:example.org";
902+
const testClient = new TestClient(myUserId, "DEVICE", "ACCESS_TOKEN", undefined, {
903+
timelineSupport: false,
904+
});
905+
const client = testClient.client;
906+
client.supportsThreads = jest.fn().mockReturnValue(true);
907+
908+
// Force server-side support so threads start uninitialised
909+
const prevSupport = Thread.hasServerSideSupport;
910+
Thread.setServerSideSupport(FeatureSupport.Stable);
911+
912+
try {
913+
const room = new Room("!room:z", client, myUserId, {
914+
pendingEventOrdering: PendingEventOrdering.Detached,
915+
});
916+
jest.spyOn(client, "getRoom").mockReturnValue(room);
917+
918+
// Create a root event and thread
919+
const rootEvent = mkMessage({ room: room.roomId, user: myUserId, msg: "Root", event: true });
920+
const thread = new Thread(rootEvent.getId()!, rootEvent, {
921+
room,
922+
client,
923+
pendingEventOrdering: PendingEventOrdering.Detached,
924+
});
925+
926+
expect(thread.initialEventsFetched).toBe(false);
927+
928+
// A message inside the thread to react to
929+
const originalMessage = mkMessage({
930+
room: room.roomId,
931+
user: myUserId,
932+
msg: "Thread message",
933+
relatesTo: {
934+
"rel_type": THREAD_RELATION_TYPE.name,
935+
"event_id": thread.id,
936+
"m.in_reply_to": { event_id: thread.id },
937+
},
938+
event: true,
939+
});
940+
941+
// Create 2 reactions before the message is in the timeline (pre-init)
942+
const reaction1 = mkReaction(originalMessage, client, myUserId, room.roomId);
943+
const reaction2 = mkReaction(originalMessage, client, myUserId, room.roomId);
944+
945+
// Add reactions while thread is NOT initialised
946+
thread.addEvent(reaction1, false);
947+
thread.addEvent(reaction2, false);
948+
949+
// Relations should already include the reactions pre-init
950+
const relsBefore = thread.timelineSet.relations!.getChildEventsForEvent(
951+
originalMessage.getId()!,
952+
RelationType.Annotation,
953+
EventType.Reaction,
954+
)!;
955+
expect(relsBefore).toBeTruthy();
956+
const beforeIds = new Set(relsBefore.getRelations().map((e) => e.getId()));
957+
expect(beforeIds.size).toBe(2);
958+
959+
// Now initialise and replay
960+
// Ensure reactions are queued for replay as well
961+
expect(thread.replayEvents).toHaveLength(2);
962+
const replay = [...(thread.replayEvents || [])];
963+
thread.replayEvents = [];
964+
thread.initialEventsFetched = true;
965+
966+
// Add the original message first so it becomes findable
967+
thread.addEvent(originalMessage, false);
968+
// Replay reactions
969+
for (const ev of replay) thread.addEvent(ev, false);
970+
971+
// Ensure no duplicates after replay (idempotent aggregation)
972+
const relsAfter = thread.timelineSet.relations!.getChildEventsForEvent(
973+
originalMessage.getId()!,
974+
RelationType.Annotation,
975+
EventType.Reaction,
976+
)!;
977+
const afterIds = new Set(relsAfter.getRelations().map((e) => e.getId()));
978+
expect(afterIds.size).toBe(2);
979+
expect(afterIds).toEqual(beforeIds);
980+
} finally {
981+
// restore
982+
Thread.setServerSideSupport(prevSupport);
983+
}
984+
});
776985
});
777986
});
778987
});

src/models/thread.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,13 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
439439
* has been initialised properly.
440440
*/
441441
this.replayEvents?.push(event);
442+
443+
// For annotations (reactions), aggregate immediately (pre-init) to keep
444+
// reaction counts/summary visible while the thread is still initialising.
445+
// Only aggregate as child: parent aggregation is unnecessary here.
446+
if (event.isRelation(RelationType.Annotation)) {
447+
this.timelineSet.relations?.aggregateChildEvent(event, this.timelineSet);
448+
}
442449
} else {
443450
// Case 2: this is happening later, and we have a timeline. In
444451
// this case, these events might be out-of order.
@@ -465,10 +472,8 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
465472
} else {
466473
this.addEventToTimeline(event, toStartOfTimeline);
467474
}
475+
// Aggregation is handled by EventTimelineSet when inserting/adding.
468476
}
469-
// Apply annotations and replace relations to the relations of the timeline only
470-
this.timelineSet.relations?.aggregateParentEvent(event);
471-
this.timelineSet.relations?.aggregateChildEvent(event, this.timelineSet);
472477
}
473478

474479
public async processEvent(event: Optional<MatrixEvent>): Promise<void> {

0 commit comments

Comments
 (0)