@@ -22,7 +22,7 @@ import { FeatureSupport, Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../..
2222import { makeThreadEvent , mkThread , populateThread } from "../../test-utils/thread" ;
2323import { TestClient } from "../../TestClient" ;
2424import { 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" ;
2626import { ReceiptType } from "../../../src/@types/read_receipts" ;
2727import { getMockClientWithEventEmitter , mockClientMethodsUser } from "../../test-utils/client" ;
2828import { 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} ) ;
0 commit comments