Skip to content

Commit cac5e2d

Browse files
mperonnetclaudekirillzyusko
authored
fix: don't emit contentOffset {0,0} on the first animatedProps evaluation in ScrollViewWithBottomPadding (#1496)
## 📃 Description `ScrollViewWithBottomPadding`'s `useAnimatedProps` emits `contentOffset = {x: 0, y: 0}` on its **first** evaluation: `prevContentOffsetY` is initialized to `null`, the current `contentOffsetY.value` is `0`, and `0 !== null` passes 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 `contentOffset` prop — e.g. a virtualized chat list mounting with an initial "scroll at end" offset (this is exactly what `@legendapp/list`'s `KeyboardAwareLegendList` integration does via `renderScrollComponent={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) animated `contentInset` commit and can land short (RN's Fabric `scrollTo` command clamps against the *native* `contentInset` at 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: - `contentOffsetY` is a fresh shared value starting at `0` for every mount, so the swallowed first value is always the meaningless `{0,0}`; - any real shift (keyboard `onStart`, `useExtraContentPadding` reaction) writes a new value, which is emitted exactly as before — including a legitimate later shift *to* `0`, since by then `prevContentOffsetY.value` is no longer `null`. ## 🧐 What to verify - Chat example: keyboard open/close shifts, `extraContentPadding` changes, and interactive dismiss still behave identically (the first *real* shift is still emitted). - A wrapped ScrollView with a non-zero `contentOffset` prop now actually mounts at that offset on Fabric. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: kirillzyusko <zyusko.kirik@gmail.com>
1 parent b7b47e3 commit cac5e2d

1 file changed

Lines changed: 7 additions & 1 deletion

File tree

  • src/components/ScrollViewWithBottomPadding

src/components/ScrollViewWithBottomPadding/index.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,9 +148,15 @@ const ScrollViewWithBottomPadding = forwardRef<
148148
if (contentOffsetY) {
149149
const curr = contentOffsetY.value;
150150

151-
if (curr !== prevContentOffsetY.value) {
151+
if (prevContentOffsetY.value === null) {
152+
// Swallow the initial evaluation: emitting `contentOffset {x:0,y:0}`
153+
// in the first animatedProps run overrides the wrapped ScrollView's
154+
// own `contentOffset` prop on Fabric (e.g. a list's initial scroll
155+
// offset), making the list mount scrolled to the top natively.
152156
// eslint-disable-next-line react-compiler/react-compiler
153157
prevContentOffsetY.value = curr;
158+
} else if (curr !== prevContentOffsetY.value) {
159+
prevContentOffsetY.value = curr;
154160
result.contentOffset = { x: 0, y: curr };
155161
}
156162
}

0 commit comments

Comments
 (0)