@@ -22,7 +22,7 @@ import { FeatureSupport, Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../..
22
22
import { makeThreadEvent , mkThread , populateThread } from "../../test-utils/thread" ;
23
23
import { TestClient } from "../../TestClient" ;
24
24
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" ;
26
26
import { ReceiptType } from "../../../src/@types/read_receipts" ;
27
27
import { getMockClientWithEventEmitter , mockClientMethodsUser } from "../../test-utils/client" ;
28
28
import { ReEmitter } from "../../../src/ReEmitter" ;
@@ -773,6 +773,215 @@ describe("Thread", () => {
773
773
expect ( lastEvent ) . toBe ( message1 ) ;
774
774
expect ( firstEvent ) . toBe ( message2 ) ;
775
775
} ) ;
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
+ } ) ;
776
985
} ) ;
777
986
} ) ;
778
987
} ) ;
0 commit comments