diff --git a/docs/postthread-perf-analysis.md b/docs/postthread-perf-analysis.md deleted file mode 100644 index 7f5d3cd5d8..0000000000 --- a/docs/postthread-perf-analysis.md +++ /dev/null @@ -1,214 +0,0 @@ -# PostThread Screen Performance Analysis - -## Initial Render Waterfall - -``` -Navigation triggers PostThreadScreen mount - │ - ├─ 1. Route params parsed (name, rkey → URI) - │ setMinimalShellMode(false) - │ - ├─ 2. Dependencies resolve (blocking gates before query fires) - │ ├─ useThreadPreferences() → sort, view [from server prefs, cached] - │ ├─ useModerationOpts() [from preferences context] - │ └─ useSession() [from session context] - │ - ├─ 3. usePostThread query fires - │ ├─ placeholderData: getThreadPlaceholder() scans cache: - │ │ other thread queries → notifications → feeds → quotes → search → explore - │ │ (returns cached post if found, so user sees content immediately) - │ │ - │ └─ getPostThreadV2({anchor, sort, view, branchingFactor, below}) - │ └─ Network round-trip to AppView - │ - ├─ 4. While network pending: Render with placeholder or skeletons - │ ├─ If placeholder found: anchor post renders with cached data - │ └─ If no placeholder: anchor skeleton + reply skeletons - │ - ├─ 5. Response arrives → sortAndAnnotateThreadItems() - │ ├─ Pass 1: Build traversal metadata (depth, siblings, moderation) - │ ├─ Pass 2: Insert readMore/readMoreUp items, compute UI state - │ └─ buildThread(): Combine items + composer + skeletons - │ - ├─ 6. List renders with deferParents=true - │ ├─ Anchor post renders first (index 0) - │ ├─ Anchor onLayout fires → setDeferParents(false) - │ └─ Parent posts prepend above anchor - │ ├─ Native: maintainVisibleContentPosition keeps anchor pinned - │ └─ Web: manual scroll offset adjustment in onContentSizeChange - │ - └─ 7. Pagination as user scrolls - ├─ onStartReached: load more parents (chunks of 5) - └─ onEndReached: load more children (chunks of 50) -``` - -## Data Dependencies Graph - -``` -PostThread -├── usePostThread(anchor) -│ ├── useThreadPreferences() ← server preferences (cached) -│ │ └── usePreferencesQuery() ← app.bsky.actor.getPreferences -│ ├── useModerationOpts() ← label/moderation config (context) -│ ├── useSession() ← auth state (context) -│ ├── useMergeThreadgateHiddenReplies() ← local + server hidden replies -│ ├── useAgent() ← API client (context) -│ └── useBreakpoints() ← screen size → affects `below` param -│ -├── Query: getPostThreadV2 -│ ├── placeholderData → scans all query caches for cached post -│ └── enabled: prefs loaded AND anchor exists AND modOpts available -│ -└── Lazy Query: getPostThreadOtherV2 (only on user action) - └── enabled: otherItemsVisible AND hasOtherThreadItems -``` - -**Key observation:** The main query is gated on 3 conditions — preferences, anchor URI, -and moderation options must all be available before `getPostThreadV2` fires. If preferences -are slow to load from the server, the thread query is delayed. - -## FlatList Virtualization Config - -| Parameter | Value | Default | Impact | -|-----------|-------|---------|--------| -| `initialNumToRender` | Dynamic (from `useInitialNumToRender`) | 10 | Calculated from window height | -| `windowSize` | 7 | 21 | Renders 3.5 screens above/below viewport | -| `maxToRenderPerBatch` | 5 | 10 | Smaller batches = more responsive scrolling | -| `updateCellsBatchingPeriod` | 100ms | 50ms | Reduces render frequency | -| `onEndReachedThreshold` | 4 | 2 | Prefetches children early | -| `onStartReachedThreshold` | 1 | — | Loads parents when near top | - -## Key Performance Patterns - -### 1. Placeholder Data (eliminates blank screen) - -- `getThreadPlaceholder()` searches 6 different query caches for the post -- If found in a feed or notification, the anchor post renders instantly with cached data -- Full thread data replaces placeholder when API responds - -### 2. Deferred Parent Rendering (prevents scroll jump) - -- On mount, `deferParents=true` — only anchor + children render -- Anchor's `onLayout` callback sets `deferParents=false` -- Parents then prepend above anchor -- Native uses `maintainVisibleContentPosition` to keep anchor stable -- Web recalculates scroll offset manually - -### 3. Chunked Pagination - -- Parents: 5 at a time (avoids large prepend causing jank) -- Children: 50 at a time (generous to reduce load-more triggers) - -### 4. Lazy "Other Replies" Query - -- Moderated/filtered replies not fetched until user taps "Show other replies" -- Avoids unnecessary network and render cost - -### 5. Optimistic Reply Insertion - -- `createCacheMutator` inserts new replies directly into query cache -- No refetch needed after posting a reply -- Post shadow system updates like/reply counts without re-render of entire tree - -## Potential Performance Concerns - -### 1. `sortAndAnnotateThreadItems` is synchronous and O(n) - -- Two full passes over all thread items on every query update -- Computes moderation for every post (`moderatePost()`) -- For deeply nested threads with many replies, this could block the JS thread -- Located in `src/state/queries/usePostThread/traversal.ts` - -### 2. No explicit staleTime configured - -- The main query has no `staleTime` set, so TanStack Query treats data as immediately stale -- Navigating back to a previously viewed thread will refetch, though placeholder data covers the gap -- Could save unnecessary refetches by adding a staleTime (e.g., `STALE.SECONDS.FIFTEEN`) - -### 3. Thread preference changes re-derive everything - -- Changing sort or view triggers `prepareForParamsUpdate()` which resets `deferParents=true` -- The query key includes sort/view, so it's a fresh fetch + full re-render -- No transition animation or optimistic sorting - -### 4. Parent prepend scroll management (web) - -- Web path uses `onContentSizeChange` with manual scroll math -- `shouldHandleScroll` ref + `anchorTop` ref coordination is fragile -- Potential for scroll position glitches if content size changes during prepend - -### 5. Multiple context providers in render path - -- `PostThreadContextProvider` wraps the entire tree -- Each `ThreadItemPost` reads from thread context, session, theme, moderation, preferences -- React Compiler helps here, but deep context dependency chains can still cause cascading - re-renders if a provider value changes - -### 6. Embed loading within thread - -- Each post independently loads its embeds (images, videos, link cards, quote posts) -- No coordinated prefetching of embed content -- In a thread with many image/video embeds, this creates a waterfall of media requests -- Video embeds are particularly expensive — managed by `updateActiveVideoViewAsync` on scroll - -## API Parameter Tuning - -| Mode | `branchingFactor` | `below` (phone) | `below` (desktop) | -|------|-------------------|------------------|--------------------| -| Linear | 1 | 10 | 10 | -| Tree | undefined (all) | 4 | 6 | - -Linear view fetches deeper (10 levels) but only one branch. Tree view fetches shallower -(4-6 levels) but all branches. This is a reasonable tradeoff — tree view with unlimited -depth would be extremely expensive. - -## Component Render Tree - -``` -PostThread (main component) -├── PostThreadContextProvider -│ ├── Layout.Header.Outer -│ │ ├── Layout.Header.BackButton -│ │ ├── Layout.Header.TitleText ("Post") -│ │ └── HeaderDropdown (sort/view controls) -│ │ -│ └── List (FlatList with virtualization) -│ └── renderItem() dispatches to: -│ ├── ThreadItemAnchor (depth=0, large format) -│ │ └── PostControls (big variant), engagement stats, full embed -│ ├── ThreadItemPost (depth<0 parents, depth>0 linear replies) -│ │ └── PostControls (standard), condensed embed -│ ├── ThreadItemTreePost (depth>0, tree view) -│ │ └── Indented with vertical connection lines, compact controls -│ ├── ThreadItemPostTombstone (blocked/not-found) -│ ├── ThreadItemReadMore / ThreadItemReadMoreUp -│ ├── ThreadItemShowOtherReplies -│ ├── ThreadComposePrompt -│ └── Skeleton variants (anchor, reply, composer) -│ -└── MobileComposePrompt (floating reply button on native) -``` - -## Key Files Reference - -| File | Purpose | -|------|---------| -| `src/screens/PostThread/index.tsx` | Main component, list rendering, scroll handling | -| `src/view/screens/PostThread.tsx` | Navigation screen wrapper | -| `src/state/queries/usePostThread/index.ts` | Query hook, state management | -| `src/state/queries/usePostThread/traversal.ts` | Sorting, annotation, moderation | -| `src/state/queries/usePostThread/queryCache.ts` | Optimistic cache mutation | -| `src/state/queries/usePostThread/const.ts` | API params (branching factor, below) | -| `src/screens/PostThread/const.ts` | UI constants (spacing, widths) | -| `src/screens/PostThread/components/*.tsx` | Individual item renderers | - -## Summary - -The PostThread screen is well-optimized for the common case. The placeholder data system, -deferred parent rendering, and chunked pagination create a smooth experience for typical -threads. The main areas where performance could degrade are: - -1. **Very large threads** where `sortAndAnnotateThreadItems` becomes expensive on the JS thread -2. **Media-heavy threads** where embed loading creates waterfalls of network requests -3. **Lack of staleTime** causing unnecessary refetches on re-navigation -4. **Web scroll management** during parent prepending being fragile diff --git a/src/screens/PostThread/index.tsx b/src/screens/PostThread/index.tsx index 385879f31e..8564ac43c1 100644 --- a/src/screens/PostThread/index.tsx +++ b/src/screens/PostThread/index.tsx @@ -1,4 +1,11 @@ -import {useCallback, useEffect, useMemo, useRef, useState} from 'react' +import { + startTransition, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' import {useWindowDimensions, View} from 'react-native' import Animated, {useAnimatedStyle} from 'react-native-reanimated' import {Trans} from '@lingui/react/macro' @@ -389,12 +396,34 @@ export function PostThread({uri}: {uri: string}) { return results }, [thread, deferParents, maxParentCount, maxChildrenCount]) + /** + * Defer rendering reply skeletons so that the anchor post (from cache) + * can paint without being blocked by skeleton layout work. On mount, + * skeletons are filtered out. After the first render, they're added + * back via a low-priority transition. + */ + const [showReplySkeletons, setShowReplySkeletons] = useState(false) + useEffect(() => { + if (thread.state.isPlaceholderData && !showReplySkeletons) { + startTransition(() => { + setShowReplySkeletons(true) + }) + } + }, [thread.state.isPlaceholderData, showReplySkeletons]) + + const deferredSlices = useMemo(() => { + if (showReplySkeletons) return slices + return slices.filter( + item => !(item.type === 'skeleton' && item.item === 'reply'), + ) + }, [slices, showReplySkeletons]) + const isTombstoneView = useMemo(() => { - if (slices.length > 1) return false - return slices.every( + if (deferredSlices.length > 1) return false + return deferredSlices.every( s => s.type === 'threadPostBlocked' || s.type === 'threadPostNotFound', ) - }, [slices]) + }, [deferredSlices]) const renderItem = useCallback( ({item, index}: {item: ThreadItem; index: number}) => { @@ -547,7 +576,7 @@ export function PostThread({uri}: {uri: string}) { ) : (