Skip to content

fix: don't emit contentOffset {0,0} on the first animatedProps evaluation in ScrollViewWithBottomPadding#1496

Open
mperonnet wants to merge 1 commit into
kirillzyusko:mainfrom
mperonnet:fix/initial-animated-content-offset-overrides-prop
Open

fix: don't emit contentOffset {0,0} on the first animatedProps evaluation in ScrollViewWithBottomPadding#1496
mperonnet wants to merge 1 commit into
kirillzyusko:mainfrom
mperonnet:fix/initial-animated-content-offset-overrides-prop

Conversation

@mperonnet

Copy link
Copy Markdown

📃 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

…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>
@github-actions

Copy link
Copy Markdown
Contributor

📊 Package size report

Current size Target Size Difference
319223 bytes 318458 bytes 765 bytes 📈

@kirillzyusko kirillzyusko self-assigned this Jun 12, 2026
@kirillzyusko kirillzyusko added 🐛 bug Something isn't working KeyboardAwareScrollView 📜 Anything related to KeyboardAwareScrollView component KeyboardChatScrollView 💬 Anything about chat functionality labels Jun 12, 2026
@kirillzyusko kirillzyusko self-requested a review June 12, 2026 13:58
// eslint-disable-next-line react-compiler/react-compiler
prevContentOffsetY.value = curr;
} else if (curr !== prevContentOffsetY.value) {
// eslint-disable-next-line react-compiler/react-compiler

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please remove this line?

Something is wrong with eslint setup in the project and we need to use ignore only once 🤷‍♂️

@kirillzyusko

Copy link
Copy Markdown
Owner

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 🤞

@kirillzyusko

Copy link
Copy Markdown
Owner

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 contentOffsetY is 0 on first render is because we initialize it as 0 and then apply it later via animatedProps. Maybe the correct fix would be to pass correct initial value into useSharedValue? What do you think?

@mperonnet

Copy link
Copy Markdown
Author

Good question — I considered seeding the shared value, but went with swallowing the first evaluation for two reasons:

  1. The library doesn't know the correct initial value. contentOffsetY is owned by useChatKeyboard and only encodes keyboard-driven shifts; the actual initial scroll position belongs to the consumer, and it reaches the wrapped ScrollView as a plain contentOffset prop inside {...rest}ScrollViewWithBottomPadding never inspects it. With renderScrollComponent integrations (e.g. @legendapp/list), the list computes and injects that prop itself, and it can change between the list's first render and its retarget passes, so reading rest.contentOffset?.y once at mount can still race and seed a stale value.

  2. Swallowing the first run is equivalent in effect, with less coupling. Whatever the initial contentOffsetY is, recording it without emitting means the wrapped ScrollView keeps whichever contentOffset prop its owner set (including none), and the first real shift — keyboard onStart, useExtraContentPadding reaction — emits exactly as before, since by then prevContentOffsetY is non-null and the value actually changed. The shift pipeline is unchanged; only the meaningless {x: 0, y: 0} clobber on mount disappears.

That said, if you'd rather keep the emission semantics uniform and seed from the consumer (e.g. accept an explicit initialContentOffsetY prop, or read rest.contentOffset at construction), happy to rework the PR that way — your call.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🐛 bug Something isn't working KeyboardAwareScrollView 📜 Anything related to KeyboardAwareScrollView component KeyboardChatScrollView 💬 Anything about chat functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants