# PR #336 perf regression diagnosis — plan-first

**Status:** investigation complete, fixes proposed, awaiting user pick.
**Reported regression:** Ford stock detail → Analyst Forecasts tab → UI 34fps / JS 46fps / RAM 288.61 MB.
**Expected (pre-rewrite baseline target):** UI 60fps / JS 60fps, RAM substantially lower (PR #328 set the cellular baseline).
**Files reviewed:**
- `src/features/stock-detail/presentation/screens/StockDetailScreen.tsx` (post-rewrite, this PR)
- `src/features/stock-detail/presentation/screens/StockDetailTabs.tsx` (post-rewrite, this PR)
- `src/features/stock-detail/presentation/hooks/usePrefetchSlowTabs.ts` (PR #328, unchanged)
- `src/features/stock-detail/presentation/hooks/useDeferredMount.ts` (PR #328, unchanged)
- `src/features/stock-detail/presentation/hooks/useStockDetailViewModel.ts` (unchanged — snapshot via React Query, stable refs across ticks)
- `src/features/stock-detail/presentation/components/CollapsedHeader.tsx` (unchanged — `memo` wrapper preserved)
- `src/features/stock-detail/presentation/components/ExpandedHeader.tsx` (unchanged — `memo` wrapper preserved)
- `node_modules/react-native-collapsible-tab-view/src/Container.tsx` (lib source — `lazy` prop semantics)
- `node_modules/react-native-collapsible-tab-view/src/Lazy.tsx` (lib source — `startMounted` semantics)

---

## Smoking gun — Hypothesis #1 (HIGHEST LIKELIHOOD)

**The lib eagerly mounts ALL 12 tabs at Container mount time when the `lazy` prop is omitted.**

The current PR #336 code calls `<Tabs.Container>` without the `lazy` prop. From the lib's `Container.tsx`:

```tsx
<Lazy
  startMounted={lazy ? undefined : true}   // ← when lazy is undefined, startMounted=true
  cancelLazyFadeIn={!lazy ? true : !!cancelLazyFadeIn}
  ...
```

And in `Lazy.tsx`:

```tsx
const shouldStartMounted =
  typeof _startMounted === 'boolean'   // ← gets `true` from the parent
    ? _startMounted                     // ← so shouldStartMounted = true
    : focusedTab.value === name
```

Net effect: **every `<Tabs.Tab>` child mounts its `<TabScrollPane>` → `<TabBody>` subtree at Container mount, regardless of which tab is focused.**

Pre-rewrite, `StockDetailTabs.tsx` rendered only the **active tab's body** (single-content swap inside one outer ScrollView). Post-rewrite, **all 12 tabs mount immediately** because the lib defaults to eager mount.

Each `TabBody` then renders content via `LazyTab` (PR #328 pattern using `useDeferredMount` → `InteractionManager.runAfterInteractions`). Because `useDeferredMount` keys on `tabKey` and fires once per mount, **all 12 LazyTab gates resolve in roughly the same interaction frame**, kicking off 12 simultaneous heavy mounts (Skeleton → Suspense → lazy chunk load → component render).

This explains:
- **JS 46fps**: all 12 tab bodies running their initial hooks + Suspense loaders + data fetches simultaneously saturates the JS thread.
- **UI 34fps**: scroll bridges native → JS for animated values (`useCurrentTabScrollY`, header collapse interpolation, etc.). When the JS thread can't keep up, the UI thread stalls waiting for the next animation frame's commit.
- **RAM 288.61 MB**: 12 fully-mounted tab subtrees include the wagmi/skia charts (Analyst Forecasts EPS chart, KeyMetrics charts, NanoScore visuals, Financials Skia bars, Smart Tips card, etc.). Native Skia surfaces consume GPU memory even when off-screen.

**Fix:** add `lazy` prop to `Tabs.Container`. The lib then only mounts the focused tab; others mount lazily on first visit (with the lib's built-in fade-in transition).

**LOC:** 1 line. Lowest possible risk.

---

## Hypothesis ranking

| Rank | Hypothesis | Likelihood | Evidence | Fix size |
|---|---|---|---|---|
| **#1** | Lib mounts all 12 tabs at startup (no `lazy` prop) | **VERY HIGH** | Lib source code at `Container.tsx:442`. Defaults to `startMounted=true` for every Tab. | 1 line |
| **#2** | `usePrefetchSlowTabs` fires 3 parallel prefetches at mount, compounding JS-thread load alongside #1 | MED-HIGH | Hook unchanged from PR #328, but pre-rewrite only 1 tab mounted so the compounding wasn't visible. | 5-10 lines (stagger) |
| **#3** | LazyTab's `useDeferredMount` resolves all 12 gates in the same `InteractionManager.runAfterInteractions` frame | MED-HIGH | Direct consequence of #1. Fixing #1 makes this moot for non-focused tabs. | 0 lines (if #1 fixed) |
| **#4** | wagmi/skia EPS chart in AnalystForecastsTab has heavy initial render | MED | True, but only matters when the tab is focused. Fixing #1 means it only mounts when user lands on Analyst Forecasts — same as pre-rewrite. | 0 lines (if #1 fixed) |
| **#5** | `renderHeader` / `renderTabBar` callbacks recreated on snapshot ticks | LOW | `vm.snapshot` comes from React Query, reference is STABLE between data updates. Live ticks happen inside `LiveSnapshotProvider` consumers, not in `vm.snapshot`. Memo deps don't churn per tick. | n/a |
| **#6** | Per-tab Tabs.ScrollView refs cause extra commits | LOW | `useRef` + `useEffect` register/unregister. One commit per tab on mount, none on steady-state. | n/a |
| **#7** | React Compiler not seeing the new lib-driven render path | LOW | babel config unchanged. Compiler runs on all `.tsx`. No reason to skip the new file. | n/a |
| **#8** | LiveSnapshotProvider price ticks propagating to compact-row + expanded-header re-render every frame | LOW-MED | Headers are `memo`-wrapped at top level. Inner consumers (`CompanyInfoRow` → `LivePriceText`) re-render on tick — same as pre-rewrite. Not new behavior. | n/a |
| **#9** | Lib's snap-on-release / `revealHeaderOnScroll` adds per-frame compositor work | LOW | Lib runs these as Reanimated worklets on UI thread. Not the JS-thread bottleneck. | n/a |

**Verdict:** **Hypothesis #1 explains the regression on its own.** Hypotheses #2-#4 compound the impact. Hypotheses #5-#9 are unlikely meaningful contributors.

---

## Proposed fixes (ranked by likelihood-of-fixing × lowest-risk)

### Fix A — `lazy` prop on Tabs.Container (RECOMMENDED FIRST TRY)

```diff
 <Tabs.Container
   headerHeight={HEADER_HEIGHT_HINT}
   initialTabName={initialTab}
+  lazy
   minHeaderHeight={COLLAPSED_HEADER_HEIGHT + insets.top}
   onIndexChange={nav.onIndexChange}
   ref={nav.containerRef}
   renderHeader={renderHeader}
   renderTabBar={renderTabBar}
   revealHeaderOnScroll
   snapThreshold={SNAP_THRESHOLD}
   tabBarHeight={TAB_BAR_HEIGHT_HINT}
 >
```

**Effect:**
- Only the focused tab mounts at startup.
- Non-focused tabs mount on first visit (lib fades them in over ~100ms).
- Matches pre-rewrite single-tab-active behavior.

**LOC:** 1 line.
**Risk:** very low. Lib's `lazy=true` is the canonical / documented config for screens like this.
**Reversibility:** delete the line if it breaks something.
**Expected metric impact:**
- JS thread: 46fps → 60fps (one tab's initial render instead of twelve).
- UI thread: 34fps → 60fps (no JS-bridge backlog).
- RAM: 288 MB → ~120-150 MB on landing (matches pre-rewrite, since only one tab mounted).

### Fix B — drop LazyTab's `useDeferredMount` once lib's `lazy` is active (RECOMMENDED SECOND TRY, only if A insufficient)

PR #328's `LazyTab` (in `StockDetailTabs.tsx`) wraps every tab body in a deferred-mount gate. With lib `lazy=true`, the lib already defers mount to first visit. The `LazyTab` adds a redundant InteractionManager-based defer on top of the lib's defer.

```diff
-function LazyTab({ tabKey, children }: { tabKey: StockDetailTab; children: React.ReactNode }) {
-  const ready = useDeferredMount(tabKey);
-  if (!ready) return <TabSkeleton />;
-  return <Suspense fallback={<TabSkeleton />}>{children}</Suspense>;
-}
+function LazyTab({ children }: { children: React.ReactNode }) {
+  return <Suspense fallback={<TabSkeleton />}>{children}</Suspense>;
+}
```

**Effect:**
- Remove one layer of mount-defer indirection.
- Suspense still wraps for `lazy(() => import(...))` chunk loads.

**LOC:** ~5 lines deleted.
**Risk:** low. `useDeferredMount` was a fix for the pre-rewrite arch where the active tab needed to defer until the tab-tap animation settled. With lib animation handling, this is redundant.
**Caveat:** keep this in reserve. Fix A alone is likely sufficient. Apply B only if profiler shows the lib's lazy fade-in still hitches the JS thread.

### Fix C — stagger `usePrefetchSlowTabs` (RESERVE — apply only if A+B insufficient)

```diff
 useEffect(() => {
   if (!t) return;
-  queryClient.prefetchQuery({ queryKey: [...], queryFn: ..., staleTime: STALE_TIME_6H });
-  queryClient.prefetchQuery({ queryKey: [...], queryFn: ..., staleTime: STALE_TIME_6H });
-  queryClient.prefetchQuery({ queryKey: [...], queryFn: ..., staleTime: STALE_TIME_6H });
+  // Stagger to avoid JS-thread saturation during initial paint.
+  const h0 = setTimeout(() => queryClient.prefetchQuery({ ... }), 0);
+  const h1 = setTimeout(() => queryClient.prefetchQuery({ ... }), 200);
+  const h2 = setTimeout(() => queryClient.prefetchQuery({ ... }), 400);
+  return () => { clearTimeout(h0); clearTimeout(h1); clearTimeout(h2); };
 }, [t]);
```

**Effect:**
- 3 prefetches run sequentially with 200ms gaps instead of in parallel.

**LOC:** ~15 lines.
**Risk:** low. PR #328 explicitly didn't stagger, but that was when only 1 tab mounted. Post-Fix-A this should be a noop, but it's defensive.
**Caveat:** apply only if profiler still shows JS-thread spikes on screen mount after A+B.

---

## What I am NOT proposing

- **Don't strip React.memo from headers.** Both `ExpandedHeader` and `CollapsedHeader` are `memo`-wrapped at top level. Verified via grep.
- **Don't change LiveSnapshotProvider scope.** Live ticks already only re-render leaf-level consumers (`LivePriceText` etc.), not the memoized headers. Pre-rewrite behavior, not a regression.
- **Don't touch the renderHeader / renderTabBar useCallback structure.** `vm.snapshot` is stable across live ticks (React Query gives back the same reference). Memo deps don't churn.
- **Don't disable React Compiler or change babel config.** No reason to suspect compiler regression — babel config unchanged from `main`.
- **Don't optimistic-fix candidate causes that match low-likelihood ranks (#5-#9).** Per `feedback_no_patch_and_verify_loops`: investigate first, patch surgically.

---

## Verification plan once Fix A is applied

1. Apply Fix A (1 line, `lazy` prop on Tabs.Container).
2. Rebuild EAS update on `pr-336` (no native rebuild needed — JS-only change).
3. User opens Ford stock detail on the existing dev-client build via the updated EAS bundle.
4. User measures with the same harness (UI fps / JS fps / RAM).
5. If UI=60 / JS=60 / RAM ≈ pre-rewrite baseline → diagnosis confirmed, Fix A sufficient.
6. If RAM still elevated → Fix B (drop LazyTab's defer).
7. If JS-thread spike still visible on initial paint → Fix C (stagger prefetch).

**No flip to ready** until perf is restored.

---

## Why the sandbox passed but production regressed

Per the original investigation: sandbox had 1 chart + 3 simple tabs + scroll fillers. Production has 12 tabs each with real wagmi/skia charts, real data hooks, real live snapshots, AI summary overlay, tour overlay potential, smart-tips, etc.

The sandbox **did not exercise the all-tabs-mounted regression** because the sandbox had only 3 tabs and each tab was trivial. The cost of "mount 3 trivial tabs at startup" was sub-perceptible. The cost of "mount 12 chart-bearing tabs at startup" is what we see now.

**Lesson for future sandboxes:** include tab-count parity AND content-weight parity with production when verifying lib-mount semantics. The F1/F2/F3 falsifiers in the original sandbox tested per-component behavior (crosshair, sticky-row, coord math), not aggregate steady-state load — and the lib's eager-mount default wasn't on the falsifier list.
