You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
In a chat-style composition (scaleY(-1) container + reversed data + per-cell un-flip, bottom anchored at scroll 0), appending self-sizing items commits one painted frame at the items' estimated positions before the real measure/correction lands. On screen this reads as a flash: the new rows spread out with estimate-sized gaps between them, and existing content is displaced up by the total estimate — then the next frame snaps everything correct.
Isolated to a bare LegendList — no gestures, no keyboard, no other libraries in the tree.
Environment
@legendapp/list3.3.2 (identical behavior on 3.2.0 — frame profiles byte-equal across the two)
react-native 0.81.5, Expo ~54, new architecture (Fabric), Hermes
iOS 26.4 simulator (iPhone 17 Pro Max); recorded at 60fps via simctl io recordVideo
Minimal repro
importReact,{useCallback,useMemo,useState}from'react';import{Button,Text,View}from'react-native';import{LegendList}from'@legendapp/list';constINVERT={transform: [{scaleY: -1asconst}]};constTALL=(i: number)=>Array.from({length: 9},(_,l)=>`Seed ${i} line ${l+1} — tall self-sizing row.`).join('\n');exportfunctionRepro(){const[rows,setRows]=useState([{id: 's1',text: TALL(1)},{id: 's2',text: 'Short reply one.'},{id: 's3',text: TALL(2)},{id: 's4',text: 'Short reply two.'},]);// Chronological; newest LAST — reversed so newest is data[0], the scaleY// flip restores visual order with the bottom self-anchored at scroll 0.constdata=useMemo(()=>rows.slice().reverse(),[rows]);constrenderItem=useCallback(({ item }: any)=>(<Viewstyle={INVERT}><Viewstyle={{padding: 12,marginVertical: 6,marginHorizontal: 16,borderRadius: 12,backgroundColor: '#7c6fd6'}}><Textstyle={{fontSize: 13.5,lineHeight: 21,color: 'white'}}>{item.text}</Text></View></View>),[]);// Mimics a chat send: ONE state update appending 3 rows (message + stub + footer).constappend3=()=>setRows((prev)=>{constn=prev.length;return[...prev,{id: `u${n}`,text: `Sent message ${n} — short.`},{id: `t${n}`,text: ' '},{id: `f${n}`,text: `footer ${n}`}];});return(<Viewstyle={{flex: 1}}><LegendListdata={data}keyExtractor={(r: any)=>r.id}renderItem={renderItem}style={INVERT}contentContainerStyle={{paddingTop: 90,paddingBottom: 10}}estimatedItemSize={140}recycleItems/><Buttontitle="append 3"onPress={append3}/></View>);}
Steps: mount, tap "append 3" once. Expected: the three rows appear at the bottom in one clean frame. Actual: for exactly one frame, the three rows paint spread apart at estimate-sized offsets (each roughly estimatedItemSize-spaced rather than at measured positions) with the previous content displaced upward; the next frame corrects.
Measurement
Frame-by-frame difference (ffmpeg tblend=difference → signalstats YAVG over 60fps captures): the append frame pair spikes to 57.6 / 54.7 on the bare repro above (quiescent frames are <0.1). In our production chat list the same signature is a 24–26 spike, one frame, on every send. No scroll event reaches JS for that frame (an app-side onScroll clamp — see #492 — never observes it), so it appears the paint happens on the UI thread between the insert layout (estimated sizes) and the applied measurement.
Things we ruled out
Estimate accuracy is not sufficient to fix it: we locally patched in the per-item estimate hook proposed in At-bottom/followOutput semantics that hold under estimate re-layout (chat lists), or a per-item size estimate hook #492 (getEstimatedItemSize(item, index, type) consulted before the per-type average in getItemSize) and fed calibrated, near-exact heights — the flash frame still paints (the gaps shrink with better estimates, but a mispainted frame still commits). The defect looks like paint ordering (estimated layout is allowed to reach the screen), not estimate quality.
Forcing early measurement from the outside makes it worse: driving ref.setItemSize(...) with correct sizes at insert, or triggering the item's useSyncLayout() on mount, both destabilized the bottom anchor (offset excursions of 13k–32k px that our at-bottom clamp had to recover).
Not the keyboard, not gestures (repro is driven programmatically with neither).
Same at-bottom/chat family as #492 (estimate corrections vs the bottom anchor), but a distinct defect: #492 asks for better inputs (per-item estimates, a followOutput contract); this one is about a frame being painted from estimated positions at all. A followOutput/at-bottom contract that suppresses paints until appended items are measured (or applies the batched sync measure before the first paint on Fabric) would fix both.
60fps .mov captures (bare repro + production) available on request.
Summary
In a chat-style composition (scaleY(-1) container + reversed data + per-cell un-flip, bottom anchored at scroll 0), appending self-sizing items commits one painted frame at the items' estimated positions before the real measure/correction lands. On screen this reads as a flash: the new rows spread out with estimate-sized gaps between them, and existing content is displaced up by the total estimate — then the next frame snaps everything correct.
Isolated to a bare
LegendList— no gestures, no keyboard, no other libraries in the tree.Environment
@legendapp/list3.3.2 (identical behavior on 3.2.0 — frame profiles byte-equal across the two)simctl io recordVideoMinimal repro
Steps: mount, tap "append 3" once. Expected: the three rows appear at the bottom in one clean frame. Actual: for exactly one frame, the three rows paint spread apart at estimate-sized offsets (each roughly
estimatedItemSize-spaced rather than at measured positions) with the previous content displaced upward; the next frame corrects.Measurement
Frame-by-frame difference (ffmpeg
tblend=difference→signalstatsYAVG over 60fps captures): the append frame pair spikes to 57.6 / 54.7 on the bare repro above (quiescent frames are <0.1). In our production chat list the same signature is a 24–26 spike, one frame, on every send. No scroll event reaches JS for that frame (an app-side onScroll clamp — see #492 — never observes it), so it appears the paint happens on the UI thread between the insert layout (estimated sizes) and the applied measurement.Things we ruled out
getEstimatedItemSize(item, index, type)consulted before the per-type average ingetItemSize) and fed calibrated, near-exact heights — the flash frame still paints (the gaps shrink with better estimates, but a mispainted frame still commits). The defect looks like paint ordering (estimated layout is allowed to reach the screen), not estimate quality.ref.setItemSize(...)with correct sizes at insert, or triggering the item'suseSyncLayout()on mount, both destabilized the bottom anchor (offset excursions of 13k–32k px that our at-bottom clamp had to recover).Relation to #492
Same at-bottom/chat family as #492 (estimate corrections vs the bottom anchor), but a distinct defect: #492 asks for better inputs (per-item estimates, a followOutput contract); this one is about a frame being painted from estimated positions at all. A followOutput/at-bottom contract that suppresses paints until appended items are measured (or applies the batched sync measure before the first paint on Fabric) would fix both.
60fps .mov captures (bare repro + production) available on request.