@@ -71,4 +71,166 @@ describe("RecyclerViewManager", () => {
7171 expect ( consoleWarnSpy ) . not . toHaveBeenCalled ( ) ;
7272 } ) ;
7373 } ) ;
74+
75+ describe ( "processDataUpdate — layout invalidation on in-place data swap" , ( ) => {
76+ interface Item {
77+ id : number ;
78+ }
79+ const keyExtractor = ( item : Item ) => `item-${ item . id } ` ;
80+
81+ const createGridManager = ( data : Item [ ] , overrides = { } ) => {
82+ const props = {
83+ data,
84+ renderItem : jest . fn ( ) ,
85+ keyExtractor,
86+ numColumns : 2 ,
87+ ...overrides ,
88+ } as FlashListProps < Item > ;
89+ const manager = new RecyclerViewManager < Item > ( props ) ;
90+ manager . processDataUpdate ( ) ;
91+ manager . updateLayoutParams ( { width : 400 , height : 900 } , 0 ) ;
92+ manager . processDataUpdate ( ) ;
93+ return manager ;
94+ } ;
95+
96+ const markAsMeasured = (
97+ manager : RecyclerViewManager < Item > ,
98+ indices : number [ ] ,
99+ height : number
100+ ) => {
101+ for ( const i of indices ) {
102+ const layout = manager . getLayout ( i ) ;
103+ layout . isHeightMeasured = true ;
104+ layout . minHeight = height ;
105+ layout . height = height ;
106+ }
107+ } ;
108+
109+ it ( "invalidates cached height for indices whose key changed" , ( ) => {
110+ const initialData : Item [ ] = [ { id : 1 } , { id : 2 } , { id : 3 } , { id : 4 } ] ;
111+ const manager = createGridManager ( initialData ) ;
112+ markAsMeasured ( manager , [ 0 , 1 , 2 , 3 ] , 200 ) ;
113+
114+ // Replace items at index 0 and 2 (different identities)
115+ const swappedData : Item [ ] = [
116+ { id : 100 } ,
117+ initialData [ 1 ] ,
118+ { id : 300 } ,
119+ initialData [ 3 ] ,
120+ ] ;
121+ manager . updateProps ( {
122+ ...manager . props ,
123+ data : swappedData ,
124+ } ) ;
125+ manager . processDataUpdate ( ) ;
126+
127+ expect ( manager . getLayout ( 0 ) . isHeightMeasured ) . toBe ( false ) ;
128+ expect ( manager . getLayout ( 0 ) . minHeight ) . toBeUndefined ( ) ;
129+ expect ( manager . getLayout ( 2 ) . isHeightMeasured ) . toBe ( false ) ;
130+ expect ( manager . getLayout ( 2 ) . minHeight ) . toBeUndefined ( ) ;
131+
132+ // Unchanged identities keep their cached measurement
133+ expect ( manager . getLayout ( 1 ) . isHeightMeasured ) . toBe ( true ) ;
134+ expect ( manager . getLayout ( 1 ) . minHeight ) . toBe ( 200 ) ;
135+ expect ( manager . getLayout ( 3 ) . isHeightMeasured ) . toBe ( true ) ;
136+ expect ( manager . getLayout ( 3 ) . minHeight ) . toBe ( 200 ) ;
137+ } ) ;
138+
139+ it ( "invalidates every index when the whole array is replaced" , ( ) => {
140+ const initialData : Item [ ] = [ { id : 1 } , { id : 2 } , { id : 3 } , { id : 4 } ] ;
141+ const manager = createGridManager ( initialData ) ;
142+ markAsMeasured ( manager , [ 0 , 1 , 2 , 3 ] , 200 ) ;
143+
144+ // Category switch — entirely different items at every position
145+ const swappedData : Item [ ] = [
146+ { id : 10 } ,
147+ { id : 20 } ,
148+ { id : 30 } ,
149+ { id : 40 } ,
150+ ] ;
151+ manager . updateProps ( {
152+ ...manager . props ,
153+ data : swappedData ,
154+ } ) ;
155+ manager . processDataUpdate ( ) ;
156+
157+ for ( let i = 0 ; i < swappedData . length ; i ++ ) {
158+ expect ( manager . getLayout ( i ) . isHeightMeasured ) . toBe ( false ) ;
159+ expect ( manager . getLayout ( i ) . minHeight ) . toBeUndefined ( ) ;
160+ }
161+ } ) ;
162+
163+ it ( "does not invalidate when the same items are re-emitted at the same positions" , ( ) => {
164+ const initialData : Item [ ] = [ { id : 1 } , { id : 2 } , { id : 3 } , { id : 4 } ] ;
165+ const manager = createGridManager ( initialData ) ;
166+ markAsMeasured ( manager , [ 0 , 1 , 2 , 3 ] , 200 ) ;
167+
168+ // New array reference, same identities — e.g. a query refetch returning equal data
169+ const sameData : Item [ ] = initialData . map ( ( item ) => ( { id : item . id } ) ) ;
170+ manager . updateProps ( {
171+ ...manager . props ,
172+ data : sameData ,
173+ } ) ;
174+ manager . processDataUpdate ( ) ;
175+
176+ for ( let i = 0 ; i < sameData . length ; i ++ ) {
177+ expect ( manager . getLayout ( i ) . isHeightMeasured ) . toBe ( true ) ;
178+ expect ( manager . getLayout ( i ) . minHeight ) . toBe ( 200 ) ;
179+ }
180+ } ) ;
181+
182+ it ( "does not invalidate measured heights for indices that survived an append" , ( ) => {
183+ const initialData : Item [ ] = [ { id : 1 } , { id : 2 } ] ;
184+ const manager = createGridManager ( initialData ) ;
185+ markAsMeasured ( manager , [ 0 , 1 ] , 200 ) ;
186+
187+ const appendedData : Item [ ] = [ ...initialData , { id : 3 } , { id : 4 } ] ;
188+ manager . updateProps ( {
189+ ...manager . props ,
190+ data : appendedData ,
191+ } ) ;
192+ manager . processDataUpdate ( ) ;
193+
194+ // Pre-existing indices: keys unchanged, so invalidation must not reset
195+ // `isHeightMeasured` or `height` (the per-row normalization that runs
196+ // when new indices are appended is a separate concern).
197+ expect ( manager . getLayout ( 0 ) . isHeightMeasured ) . toBe ( true ) ;
198+ expect ( manager . getLayout ( 0 ) . height ) . toBe ( 200 ) ;
199+ expect ( manager . getLayout ( 1 ) . isHeightMeasured ) . toBe ( true ) ;
200+ expect ( manager . getLayout ( 1 ) . height ) . toBe ( 200 ) ;
201+ } ) ;
202+
203+ it ( "is a no-op when keyExtractor is not provided" , ( ) => {
204+ const initialData : Item [ ] = [ { id : 1 } , { id : 2 } , { id : 3 } , { id : 4 } ] ;
205+ const props = {
206+ data : initialData ,
207+ renderItem : jest . fn ( ) ,
208+ numColumns : 2 ,
209+ } as FlashListProps < Item > ;
210+ const manager = new RecyclerViewManager < Item > ( props ) ;
211+ manager . processDataUpdate ( ) ;
212+ manager . updateLayoutParams ( { width : 400 , height : 900 } , 0 ) ;
213+ manager . processDataUpdate ( ) ;
214+ markAsMeasured ( manager , [ 0 , 1 , 2 , 3 ] , 200 ) ;
215+
216+ const swappedData : Item [ ] = [
217+ { id : 10 } ,
218+ { id : 20 } ,
219+ { id : 30 } ,
220+ { id : 40 } ,
221+ ] ;
222+ manager . updateProps ( {
223+ ...manager . props ,
224+ data : swappedData ,
225+ } ) ;
226+ manager . processDataUpdate ( ) ;
227+
228+ // Without stable keys we cannot tell positions apart safely, so we leave
229+ // the cached measurements alone (matching the pre-fix behavior).
230+ for ( let i = 0 ; i < swappedData . length ; i ++ ) {
231+ expect ( manager . getLayout ( i ) . isHeightMeasured ) . toBe ( true ) ;
232+ expect ( manager . getLayout ( i ) . minHeight ) . toBe ( 200 ) ;
233+ }
234+ } ) ;
235+ } ) ;
74236} ) ;
0 commit comments