Skip to content

fix: seed animatedScrollY when the sticky scroll handler attaches late - #522

Open
giaBaoJS wants to merge 1 commit into
LegendApp:mainfrom
giaBaoJS:fix/sticky-scroll-seed-animated-offset
Open

fix: seed animatedScrollY when the sticky scroll handler attaches late#522
giaBaoJS wants to merge 1 commit into
LegendApp:mainfrom
giaBaoJS:fix/sticky-scroll-seed-animated-offset

Conversation

@giaBaoJS

@giaBaoJS giaBaoJS commented Aug 13, 2026

Copy link
Copy Markdown

Fixes #512

Problem

A list mounts with empty data under contentInsetAdjustmentBehavior="automatic". When stickyHeaderIndices arrives later (data from an async query), the index-0 sticky header paints exactly one header-inset too low, overlapping the row below it, until any scroll corrects it.

Root cause

ctx.animatedScrollY is created as createAnimatedValue(0) (src/state/state.tsx:170src/platform/Animated.native.tsx:5), and the Animated.event built in useStickyScrollHandler is its only writer.

That handler is gated on stickyHeaderIndices?.length (src/platform/useStickyScrollHandler.native.ts:16), so while the indices are empty the scroll events go to the plain JS handler and nothing writes the animated value. iOS reports the inset-adjustment rest offset (-headerInset) in a scroll event at mount — exactly in that window — so it is dropped.

PositionViewSticky derives translateY purely from an interpolation of animatedScrollY with stickyStart = position + headerSize + stylePaddingTop + alignItemsAtEndPadding - stickyHeaderConfig.offset (src/components/PositionView.native.tsx:107,122-127). For item 0 with offset = 96 that gives stickyStart = -96, and the interpolation is linear with slope 1 from (stickyStart → position):

  • stuck at 0translateY = 0 + (0 − (−96)) = 96 — one inset too low
  • seeded to −96translateY = 0 — correct

Fix

Seed animatedScrollY from state.lastNativeScroll on the transition into the animated engine. state.lastNativeScroll is assigned from the raw contentOffset in src/core/onScroll.ts:89, i.e. exactly the value the Animated.event would have written had it been attached.

Two deliberate constraints:

  • Only on the transition, tracked with a ref, not on every recompute of the memo. The memo's deps include stickyHeaderIndices?.join(","), so a sectioned list whose indices change during a scroll would otherwise re-seed from lastNativeScroll, which can lag the native offset (JS onScroll is throttled and has several early returns, e.g. onScroll.ts:57,85). Keeping Animated.event the sole owner once attached avoids introducing that jitter. Covered by the second new test.
  • RTL horizontal lists are skipped. lastNativeScroll has been converted to a logical offset for those (onScroll.ts:77-80toLogicalHorizontalOffset), so it is not the raw value Animated.event writes. Better to leave those on the existing behaviour than to seed a wrong number.

The seed cannot double-apply: setValue is an assignment, and every subsequent Animated.event write is also an assignment of the authoritative native offset.

Why not "attach whenever stickyHeaderIndices is defined"

The issue suggests that as an alternative. It does not fix the reported case: the repro is undefined → [0], so the handler is still absent for the whole window in which iOS reports the inset adjustment. It would only help a [] → [0] transition. Seeding covers both.

Scope

  • src/integrations/reanimated.tsx is untouched. It uses a separate stickyScrollOffset shared value fed by useScrollViewOffset (reanimated.tsx:90), which reads the live offset off the scroll view ref rather than accumulating from events, so it does not have the stale-zero problem.
  • src/platform/useStickyScrollHandler.ts (web) is untouched — it is a no-op passthrough.
  • The one change to a pre-existing line hoists the as unknown as Animated.Value cast (needed because tsc:src resolves @/platform/Animated to the web shim where AnimatedValue = number) from the Animated.event config up to the destructure, so both uses share it.

Verification

  • bun test1585 pass / 0 fail (baseline on main was 1583; +2 new).
  • Counterfactual: with the new tests in place and src/platform/useStickyScrollHandler.native.ts reverted to main, both new tests fail with Expected number of calls: 1 / Received number of calls: 0 — the hook never calls setValue at all today. Restoring the fix returns 4/4 pass in that file.
  • bun run lint — clean (Biome, 450 files).
  • bun run tsc:src — clean.
  • bun run build — succeeds.

Tests added to __tests__/platform/useStickyScrollHandler.native.test.tsx:

  1. mount with stickyHeaderIndices={undefined}, set state.lastNativeScroll = -96, re-render with [0]animatedScrollY.setValue(-96) called exactly once.
  2. mount already attached with [0], then change to [0, 5] with a different lastNativeScroll → still only the one initial seed, no re-seed.

The interpolation numbers above are arithmetic on PositionView.native.tsx:122-127, not a rendered measurement — react-native is fully mocked in this suite (__tests__/setup.ts:51), so a real Animated.Value interpolation cannot be evaluated there.

iOS simulator confirmation

Verified the user-visible symptom end to end on an iPhone 17 Pro simulator (iOS 26.3), using example/ with a throwaway screen that mirrors the repro: transparent header, contentInsetAdjustmentBehavior="automatic", stickyHeaderConfig={{ offset: 96 }}, and data + stickyHeaderIndices both going from empty/undefined to populated/[0] after 1.5s. Metro resolves @legendapp/list/* straight to ../src, so the same build was used for both runs and only the hook file was swapped.

Accessibility-frame positions of the sticky header row, normalized against the 874pt screen:

STICKY HEADER 0 y Row 0 y Result
main 0.262 0.217 header renders below Row 0 and covers Row 1
this PR 0.152 0.217 header renders above Row 0, no overlap

The displacement on main is 0.262 − 0.152 = 0.110, i.e. 0.110 × 874 ≈ 96.1pt — exactly the one header inset the issue describes. A ~1pt scroll on main snapped it into the correct position, matching the reported "any scroll corrects it" behaviour. The screen was reached by a fresh mount both times (navigate away, deep-link back in).

`useStickyScrollHandler` only attaches its `Animated.event` while
`stickyHeaderIndices` is non-empty, and that event is the only writer of
`ctx.animatedScrollY`. When a list mounts with empty data and the indices
arrive later, every scroll event before the attach reaches only the plain JS
handler and is dropped, so `animatedScrollY` still holds its initial `0`.

`PositionViewSticky` derives `translateY` purely from an interpolation of that
value, with `stickyStart = position - stickyHeaderConfig.offset`. On iOS with
`contentInsetAdjustmentBehavior="automatic"` the true rest offset is
`-headerInset`, reported in a scroll event at mount, so a stuck `0` places the
index-0 sticky header exactly one inset too low until the user scrolls.

Seed `animatedScrollY` from `state.lastNativeScroll` on the transition into the
animated engine, which is the raw `contentOffset` the `Animated.event` would
have written. Seeding only on that transition keeps `Animated.event` the sole
owner afterwards, so a later `stickyHeaderIndices` change cannot overwrite a
live value with a JS offset that lags during a scroll. RTL horizontal lists are
skipped because `lastNativeScroll` is a logical offset there, not the raw one.

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

Labels

None yet

Projects

None yet

1 participant