# NAN-429 step-back — collapsible header architecture reconsideration

**Date:** 2026-05-20
**Trigger:** PR #336 Bug 1 v2 A+B fix (commit `520723cf`) made the bug worse. Reverted to `570f766e` (functional state matching `b1f55c2f`).
**Status:** plan-doc only. No code patches until user picks an architectural direction.

---

## Why A+B made things worse (post-mortem)

I shipped A+B based on a hypothesis. User's symptom after deploy: **"more unreliable, more often get stuck even on light scrolls."** That's strong evidence the fixes introduced races, not solved them. Best-guess root cause for the regression:

### A's idempotency claim was wrong

I asserted `onMomentumEnd` is idempotent because "the second call re-reads scrollYCurrent and converges to the same target." That's only true at steady state. In flight:

1. First `onMomentumEnd` fires (e.g. via native `onMomentumScrollEnd` deceleration end)
2. Sets `scrollAnimation.value = scrollYCurrent.value` (e.g. 180) then `withTiming(target=300, 200ms)`
3. `useAnimatedReaction` starts driving `scrollTo` at each frame → native ScrollView scrolls 180 → 200 → 220 …
4. Native ScrollView's intermediate `onScroll` events update `scrollYCurrent` continuously
5. Second `onMomentumEnd` fires (e.g. via afterDrag's manual call — no longer cancelled by `onMomentumBegin`)
6. Reads `scrollYCurrent.value` = ~230 (mid-snap interpolating value)
7. Sets `scrollAnimation.value = 230` (overrides the in-flight withTiming!) then `withTiming(target=300, 200ms)`
8. Reanimated cancels the prior withTiming (because we just reassigned `.value`) and starts new from 230

The net behaviour is **snap-restart from a moving baseline**. If a third `onMomentumEnd` arrives (e.g. via a transient gesture), restart again. Each restart adds ~40ms latency. The snap either completes very late or never completes if restarts keep arriving.

**A was wrong:** `onMomentumEnd` is idempotent at steady state, NOT during the snap animation. Concurrent firings produce races.

### B's unconditional 60ms safety net guaranteed double-fire

`setTimeout(60ms)` fires regardless of whether the worklet path already triggered `onMomentumEnd`. Result:
- Normal gesture: `afterDrag` fires `onMomentumEnd` at ~17ms; setTimeout fires it again at 60ms → mid-snap restart at ~60ms → snap ends ~250ms instead of ~200ms (visible jitter)
- Light scroll with momentum: native `onMomentumScrollEnd` fires `onMomentumEnd` at ~30-100ms; setTimeout fires it again at 60ms → race depending on order
- User-perceived: scroll feels twitchy, "more often get stuck" because the restart cascades

**B was wrong:** the safety net needed to be conditional (only fire if `onMomentumEnd` HASN'T already fired). Without that guard, every gesture gets the double-fire.

### Combined damage

A + B together meant that every paused release produced **3-4** `onMomentumEnd` calls:
1. afterDrag's manual call (now uncancelled by A)
2. setTimeout 60ms (B)
3. Native onMomentumScrollEnd (if iOS new-arch fires it)
4. Possibly another spurious one from gesture state-machine quirks

The light-scroll regression hint matches this — even small drags triggered the cascade because every `onEndDrag` schedules at least 2 calls (afterDrag + setTimeout).

---

## The real lesson

The lib's snap state-machine is **fragile under Reanimated v4 + iOS new-arch (Fabric) + pager-view 7.0.2**. Each patch we add is a guess at a complex interaction; without device-side telemetry, we're shooting in the dark. v1 worked. v2 didn't. Without a falsifier we cannot reliably distinguish which lib code paths are firing at which times.

**Patching the lib's worklet snap machine further is not a viable direction.** Even if we get v3 to work, we lose confidence that a v4 or Reanimated upgrade won't break it again.

---

## Architectural options

| # | Option | Effort | Risk | UX impact | Recommendation |
|---|---|---|---|---|---|
| (a) | `react-native-tab-view` + custom Reanimated collapsible header | 1-2 wk | Medium | None | ⭐ **Recommended** |
| (b) | Custom-build everything (pager + collapse) from scratch | 2-3 wk | High | None | Reject (scope) |
| (c) | Lock pager `scrollEnabled` tighter to header state | 2-3 days | High | Minor | Reject (band-aid) |
| (d) | Ditch tabs entirely — single ScrollView with sections | 3-4 days | Low | **Major regression** | Reject |
| (e) | Wait for `collapsible-tab-view` 9.x with upstream fix | unknown | Lib roadmap unclear | Status quo bugs persist | Reject (passive) |
| (f) | Pin to an older lib version (7.x or earlier) | 1-2 days | Medium | Likely some regressions | Investigate first as cheap escape valve |
| (g) | Upstream a comprehensive fix PR to v8 | 1 wk + roundtrip | Cannot control merge timeline | Lib improvements help others | Possible follow-up after (a) ships |

### Detailed evaluation

#### (a) react-native-tab-view + custom collapsible header — RECOMMENDED

**`react-native-tab-view`** is the mature, well-maintained tab pager from `@react-navigation`. We get:
- Robust pager animations (swipe between tabs, smooth transitions)
- Mature `onIndexChange`, `setIndex` semantics
- Lazy mounting per tab
- Active maintenance, regular Reanimated v4 compatibility updates
- Used by `react-navigation` itself — battle-tested across thousands of apps

**Custom collapsible header** built on top:
- Owner: us. We control the snap logic, scroll coordination, and gesture handling.
- Implementation: a `useCollapsibleHeader` hook (which we already had pre-migration!) that:
  - Owns a `headerTranslateY` SharedValue driven by an onScroll handler we wire
  - Snap on `onScrollEndDrag` via `runOnJS` → `scrollTo({y: target, animated: true})` using the native iOS animation (no Reanimated worklet race)
  - Per-tab scroll refs registered into a single `useTabScrollRegistry`-style map
  - No cross-tab sync — each tab's scroll is independent (matches user mental model anyway)

**LOC estimate:** 250-400 lines for the new orchestration; ~80% net reduction from the lib's surface area.

**Pros:**
- Battle-tested pager underneath
- We own (and can debug) the only fragile piece — the snap
- No Reanimated v4 + Fabric + pager-view 7.0.2 + lib v8 interaction matrix to wrangle
- `useCollapsibleHeader.ts` existed pre-migration; ~70% of the logic can be ported
- Animated:true on native scrollTo means iOS controls the snap animation — no worklet race

**Cons:**
- 1-2 wk of focused work
- New code surface to maintain (but smaller than the current patched lib)
- Loses the lib's cross-tab scroll sync (acceptable — debatable UX value)

**Confidence in fix:** 85% — directly addresses the architectural fragility

#### (b) Custom-build pager too

Reject. Adds 1+ wk of pager work that `react-native-tab-view` already solves. No reason to reinvent it.

#### (c) Tighter scrollEnabled lock

Limit user gesture concurrency by toggling `scrollEnabled=false` during snap. Tried in PR #336 polish v2 (`b6772087`) — failed verification, reverted. Won't fix v2 (pause-release where no gesture is competing in the first place).

#### (d) Ditch tabs

Single ScrollView with sections. UX-major regression. Pre-migration architecture was rejected by design for good reasons. Don't go back.

#### (e) Wait for lib 9.x

`collapsible-tab-view` last released 8.0.1 in 2024. Maintainer activity unclear. Even if 9.x ships with a Fabric fix, we cannot block on an external timeline.

#### (f) Pin to lib 7.x

Worth a 1-hour investigation as an **escape valve**. If 7.x doesn't have the Fabric snap race AND has acceptable other behaviour, we could ship that immediately while planning (a). Risks: pager-view compatibility (we needed 7.0.2 for Fabric setPage), reanimated v4 compatibility, possible TypeScript types regressions.

#### (g) Upstream PR

A v8 fix PR is welcome long-term but the merge timeline is outside our control. Pursue AFTER (a) ships internally — what we learn in (a) informs the upstream fix.

---

## Recommended path

**Phase 1 (today, 1-2 hr):** investigate option (f) — try `collapsible-tab-view@7.x` (latest 7.x). If it passes the v1 + v2 + tab-pill scenarios on device, ship it as an interim — buys time for (a).

**Phase 2 (next 1-2 weeks):** implement (a) — `react-native-tab-view` + custom collapsible header. Stack it as a new branch off `feat/stock-detail-rewrite`, build incrementally, device-verify each piece (header collapse on scroll → snap behaviour → tab pill sync → lazy mount → cross-tab scroll independence).

**Phase 3 (post-ship):** consider (g) — upstream fix PR with the learnings.

**PR #336 status:** stays DRAFT + APPROVED at `570f766e` (revert state). Either:
- Merge as-is (Bugs 2+3 fixed, Bug 1 v2 persists) and tackle Bug 1 v2 via the architectural rewrite in a follow-up PR, OR
- Keep PR #336 in DRAFT until (a) or (f) lands, then ship as combined PR

User to decide which path.

---

## Open questions for user

1. **Phase 1 first (cheap escape valve)?** Try `collapsible-tab-view@7.x` for 1-2 hr to see if it sidesteps the snap race. Low cost, may unblock immediately.
2. **Or jump to Phase 2?** Skip the 7.x escape valve, commit to the `react-native-tab-view` migration. 1-2 wk timeline.
3. **PR #336 merge posture:** ship the partial fixes now (Bug 2+3 + Bug 1 v1) and treat Bug 1 v2 as a follow-up, OR hold the PR in DRAFT until the architectural fix lands?
4. **Acceptable interim UX:** if PR #336 ships now with Bug 1 v2 unfixed, is the partial-collapse stuck-state tolerable as a known issue with workaround "tap a tab to unstick"? Or is this a release-blocker?

No code patches until user picks. Standing by.
