@@ -103,6 +103,114 @@ test("buildThreadPanelData keeps direct comments unindented", () => {
103103 ) ;
104104} ) ;
105105
106+ // Per-id stabilization: thread rows feed `MessageRow` a depth-normalized copy
107+ // of each reply. When `timelineMessages` churns (typing/presence) but the
108+ // reply objects survive by reference, rebuilding the thread panel must hand
109+ // `MessageRow` the SAME normalized object reference so the row/markdown memo
110+ // hits — instead of a fresh `{ ...reply, depth }` spread every render.
111+ test ( "thread reply objects keep identity across unrelated timelineMessages churn" , ( ) => {
112+ const root = message ( { id : "root" , createdAt : 1 } ) ;
113+ const replyA = message ( {
114+ id : "a" ,
115+ createdAt : 2 ,
116+ parentId : "root" ,
117+ rootId : "root" ,
118+ depth : 1 ,
119+ tags : [ [ "e" , "root" , "" , "reply" ] ] ,
120+ } ) ;
121+ const replyB = message ( {
122+ id : "b" ,
123+ createdAt : 3 ,
124+ parentId : "a" ,
125+ rootId : "root" ,
126+ depth : 2 ,
127+ tags : [ [ "e" , "a" , "" , "reply" ] ] ,
128+ } ) ;
129+
130+ // First render of the thread.
131+ const first = buildThreadPanelData (
132+ [ root , replyA , replyB ] ,
133+ "root" ,
134+ "root" ,
135+ new Set ( [ "a" ] ) ,
136+ ) ;
137+
138+ // An unrelated channel churn produces a NEW `timelineMessages` array, but the
139+ // reply objects themselves are reused by reference (only their position in
140+ // the surrounding array changed — e.g. a presence ping or typing indicator
141+ // that the snapshot layer leaves the reply identities intact for).
142+ const churned = [
143+ message ( { id : "noise" , createdAt : 99 } ) ,
144+ root ,
145+ replyA ,
146+ replyB ,
147+ ] ;
148+ const second = buildThreadPanelData ( churned , "root" , "root" , new Set ( [ "a" ] ) ) ;
149+
150+ const firstById = new Map (
151+ first . visibleReplies . map ( ( entry ) => [ entry . message . id , entry . message ] ) ,
152+ ) ;
153+ const secondById = new Map (
154+ second . visibleReplies . map ( ( entry ) => [ entry . message . id , entry . message ] ) ,
155+ ) ;
156+
157+ assert . ok ( firstById . size > 0 , "expected at least one visible reply" ) ;
158+ for ( const [ id , normalized ] of firstById ) {
159+ assert . strictEqual (
160+ secondById . get ( id ) ,
161+ normalized ,
162+ `normalized reply ${ id } must be the SAME object reference across an unrelated churn (memo hit)` ,
163+ ) ;
164+ // Depth must still reach the row correctly via the cached object.
165+ assert . equal (
166+ typeof normalized . depth ,
167+ "number" ,
168+ `normalized reply ${ id } must carry a numeric depth` ,
169+ ) ;
170+ }
171+ } ) ;
172+
173+ test ( "thread reply objects recompute when the source reply object is replaced" , ( ) => {
174+ const root = message ( { id : "root" , createdAt : 1 } ) ;
175+ const reply = message ( {
176+ id : "a" ,
177+ createdAt : 2 ,
178+ parentId : "root" ,
179+ rootId : "root" ,
180+ depth : 1 ,
181+ tags : [ [ "e" , "root" , "" , "reply" ] ] ,
182+ } ) ;
183+
184+ const first = buildThreadPanelData ( [ root , reply ] , "root" , "root" , new Set ( ) ) ;
185+
186+ // A genuine edit/refresh: the reply is a brand-new object (new identity).
187+ const editedReply = message ( {
188+ id : "a" ,
189+ createdAt : 2 ,
190+ parentId : "root" ,
191+ rootId : "root" ,
192+ depth : 1 ,
193+ body : "edited body" ,
194+ tags : [ [ "e" , "root" , "" , "reply" ] ] ,
195+ } ) ;
196+ const second = buildThreadPanelData (
197+ [ root , editedReply ] ,
198+ "root" ,
199+ "root" ,
200+ new Set ( ) ,
201+ ) ;
202+
203+ const firstA = first . visibleReplies . find ( ( e ) => e . message . id === "a" ) ;
204+ const secondA = second . visibleReplies . find ( ( e ) => e . message . id === "a" ) ;
205+ assert . ok ( firstA && secondA , "expected reply 'a' in both renders" ) ;
206+ assert . notStrictEqual (
207+ secondA . message ,
208+ firstA . message ,
209+ "a replaced source reply must produce a fresh normalized object" ,
210+ ) ;
211+ assert . equal ( secondA . message . body , "edited body" ) ;
212+ } ) ;
213+
106214test ( "buildThreadPanelDataFromIndex matches direct panel data" , ( ) => {
107215 const root = message ( { id : "root" , createdAt : 1 } ) ;
108216 const directComment = message ( {
0 commit comments