fix: don't emit contentOffset {0,0} on the first animatedProps evaluation in ScrollViewWithBottomPadding#1496
Conversation
…tion
ScrollViewWithBottomPadding's useAnimatedProps emitted
`contentOffset = {x: 0, y: 0}` on its very first evaluation
(`prevContentOffsetY` starts at `null`, so `0 !== null` passes the guard).
On Fabric the initial animatedProps merge over the wrapped component's
props, so this overrides the ScrollView's own `contentOffset` prop —
a list mounted with an initial scroll offset (e.g. a chat list opening
at the end) actually starts scrolled to the top natively and has to be
corrected after the fact by whatever retry logic the list ships.
Swallow the initial evaluation instead: record the first value without
emitting contentOffset, and only emit on subsequent changes. The shift
mechanism is unaffected — contentOffsetY always starts at 0 and any real
shift (keyboard onStart, extra content padding change) writes a new value
which is emitted as before.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
📊 Package size report
|
| // eslint-disable-next-line react-compiler/react-compiler | ||
| prevContentOffsetY.value = curr; | ||
| } else if (curr !== prevContentOffsetY.value) { | ||
| // eslint-disable-next-line react-compiler/react-compiler |
There was a problem hiding this comment.
Could you please remove this line?
Something is wrong with eslint setup in the project and we need to use ignore only once 🤷♂️
|
I think these changes look good for me 👍 Let's just wait for CI to see if e2e tests pass and I think we can merge it then 🤞 |
|
After re-reading it more carefully I got one question: wouldn't it be more correct fix to pass initial offset into? const contentOffsetY = useSharedValue(0);Basically the reason why |
|
Good question — I considered seeding the shared value, but went with swallowing the first evaluation for two reasons:
That said, if you'd rather keep the emission semantics uniform and seed from the consumer (e.g. accept an explicit |
📃 Description
ScrollViewWithBottomPadding'suseAnimatedPropsemitscontentOffset = {x: 0, y: 0}on its first evaluation:prevContentOffsetYis initialized tonull, the currentcontentOffsetY.valueis0, and0 !== nullpasses the change guard.On Fabric, the initial animatedProps values merge over the wrapped component's own props. So when the wrapped ScrollView is given a
contentOffsetprop — e.g. a virtualized chat list mounting with an initial "scroll at end" offset (this is exactly what@legendapp/list'sKeyboardAwareLegendListintegration does viarenderScrollComponent={KeyboardChatScrollView}) — that prop is silently overridden by{x:0, y:0}and the list mounts scrolled to the top natively, while the list's JS model believes it's at its initial offset. The list is then at the mercy of its own watchdog/retry logic, whose re-dispatched scroll commands race the (also asynchronous) animatedcontentInsetcommit and can land short (RN's FabricscrollTocommand clamps against the nativecontentInsetat execution time).We hit this in production as an intermittent "conversation opens with the last message hidden behind the composer" bug on RN 0.85 / Reanimated 4 / New Arch. Full write-up of the investigation (with on-device measurements of the native inset via scroll-event
nativeEvent.contentInset): the at-rest position depended on the ordering between Reanimated's shadow-tree commit of the inset and the list's initial scroll dispatch.Fix
Swallow the initial evaluation: record the first value without emitting
contentOffset, and only emit on subsequent changes.The shift mechanism is unaffected:
contentOffsetYis a fresh shared value starting at0for every mount, so the swallowed first value is always the meaningless{0,0};onStart,useExtraContentPaddingreaction) writes a new value, which is emitted exactly as before — including a legitimate later shift to0, since by thenprevContentOffsetY.valueis no longernull.🧐 What to verify
extraContentPaddingchanges, and interactive dismiss still behave identically (the first real shift is still emitted).contentOffsetprop now actually mounts at that offset on Fabric.🤖 Generated with Claude Code