From 8cbea2b82569675200863d25a9150e38ec75337d Mon Sep 17 00:00:00 2001 From: AKolenda Date: Sun, 27 Sep 2026 13:50:28 -0600 Subject: [PATCH 1/8] fix(mobile): Home rows keep their content when they wake after a scroll Rows away from the viewport rendered a different tree from the swipeable row, so waking one remounted its content the moment the list came to rest or a finger lifted. The favicons reloaded (a visible flash) and a tap that landed on the old views was dropped before it reached the row. Dormant rows now keep the swipeable tree with the gesture disabled and no action buttons, so waking a row only adds its actions. --- apps/mobile/src/features/home/HomeScreen.tsx | 2 +- .../src/features/home/swipe-row-activation.ts | 13 ++-- .../features/home/thread-swipe-actions.tsx | 78 ++++++++----------- 3 files changed, 40 insertions(+), 53 deletions(-) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index aab366710176..ce9db75a2a70 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -134,7 +134,7 @@ interface HomeScreenProps { // measured-height pool expansion. The old tallest-card estimate (~92) fired // that warning on every ordinary shelf expand, so the average wins. const ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT = 72; -// Rows away from the viewport are cheap dormant frames (see +// Rows away from the viewport skip their swipe actions (see // swipe-row-activation), so render further ahead: a fast fling then reaches // rows that are already built instead of rows still being rebuilt. const THREAD_LIST_V2_DRAW_DISTANCE = 1_000; diff --git a/apps/mobile/src/features/home/swipe-row-activation.ts b/apps/mobile/src/features/home/swipe-row-activation.ts index d56d12cba56f..b6934216b517 100644 --- a/apps/mobile/src/features/home/swipe-row-activation.ts +++ b/apps/mobile/src/features/home/swipe-row-activation.ts @@ -1,17 +1,16 @@ import { createContext, use, useSyncExternalStore } from "react"; /** - * Full swipe rows (pan gesture, animated actions, hidden action buttons) only - * exist around the viewport. Every other Home row renders a dormant frame that - * paints the same content with a fraction of the native views, so a row the - * list rebuilds while scrolling is cheap. The scroll gate already disables + * Swipe actions (the live swipe gesture and the hidden action buttons) only + * exist around the viewport. Every other Home row is dormant: the same row + * tree without them, so a row the list rebuilds while scrolling is cheaper and + * waking it never remounts its content. The scroll gate already disables * swipes while the list moves, so rows are activated once it rests. */ export function createSwipeRowActivation() { let activeKeys = new Set(); - // Swapping a row's frame remounts it, which would cancel a press or long - // press in progress, so changes wait until every finger that started on the - // list has lifted. + // Turning a row's swipe gesture off ends a swipe in progress, so changes + // wait until every finger that started on the list has lifted. const listTouches = new Set(); let pendingKeys: ReadonlyArray | null = null; const listeners = new Set<() => void>(); diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 1d61cd3a0af2..7d72a77ac658 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -264,30 +264,13 @@ interface ThreadSwipeableProps { * open/mid-drag state can't leak onto another row. */ readonly resetKey?: string; - /** Paints the row without swipe machinery; see swipe-row-activation. */ + /** Leaves out the swipe actions and gesture; see swipe-row-activation. */ readonly dormant?: boolean; readonly simultaneousWith?: ComponentProps["simultaneousWith"]; readonly threadTitle: string; } -const closeDormant = () => {}; - export function ThreadSwipeable(props: ThreadSwipeableProps) { - if (props.dormant) { - // Mirrors ReanimatedSwipeable's container and children views. - return ( - - - {props.children(closeDormant)} - - - ); - } // Recycled content gets fresh native and animation state. Late callbacks // from the previous row retain its action, never the replacement's action. return ; @@ -432,7 +415,7 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { childrenContainerStyle={{ backgroundColor: props.backgroundColor }} containerStyle={[{ backgroundColor: props.backgroundColor }, props.containerStyle]} dragOffsetFromRight={-8} - enabled={!isDismissing && props.enabled !== false && gateEnabled} + enabled={!props.dormant && !isDismissing && props.enabled !== false && gateEnabled} enableTrackpadTwoFingerGesture={props.enableTrackpadSwipe ?? true} // Fail the swipe once the pan is vertically dominant (patched-in RNGH // prop) — otherwise trackpad scrolls with ~8px of horizontal drift @@ -467,33 +450,38 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { }} overshootFriction={1} overshootRight - renderRightActions={(_progress, translation, methods) => ( - { - activeTranslationRef.current = translation; - }} - style={actionStyle} - > - + props.dormant ? null : ( + { + activeTranslationRef.current = translation; }} - secondaryAction={resolveSecondaryAction({ - close: () => methods.close(), - onDelete: props.onDelete, - secondaryAction: props.secondaryAction, - threadTitle: props.threadTitle, - })} - translation={translation} - /> - - )} + style={actionStyle} + > + methods.close(), + onDelete: props.onDelete, + secondaryAction: props.secondaryAction, + threadTitle: props.threadTitle, + })} + translation={translation} + /> + + ) + } rightThreshold={actionsWidth * 0.42} simultaneousWith={props.simultaneousWith} > From b13d6bc801c178fab0e452a88ab9f05db59fc11c Mon Sep 17 00:00:00 2001 From: AKolenda Date: Sun, 27 Sep 2026 14:24:52 -0600 Subject: [PATCH 2/8] perf(mobile): recycled Home rows reset in place instead of remounting Every Home row now renders the swipeable, and keying it by resetKey rebuilt the whole row, favicons included, each time the list reused it for another thread while scrolling. The row now resets its swipe and animation state when resetKey changes. --- .../features/home/thread-swipe-actions.tsx | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 7d72a77ac658..7de249a0116d 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -271,9 +271,9 @@ interface ThreadSwipeableProps { } export function ThreadSwipeable(props: ThreadSwipeableProps) { - // Recycled content gets fresh native and animation state. Late callbacks - // from the previous row retain its action, never the replacement's action. - return ; + // Not keyed by resetKey: remounting a recycled row rebuilt its whole tree + // while the list scrolled. ThreadSwipeableRow resets itself in place. + return ; } function ThreadSwipeableRow(props: ThreadSwipeableProps) { @@ -330,6 +330,21 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { }; }, [actionOpacity, collapse, fallbackTranslation, finishDismiss]); + // Recycled content gets fresh swipe and animation state. Late callbacks + // from the previous content keep that render's action, never the new one's. + const resetKeyRef = useRef(props.resetKey); + useLayoutEffect(() => { + if (resetKeyRef.current === props.resetKey) return; + resetKeyRef.current = props.resetKey; + cancelAnimation(collapse); + cancelAnimation(actionOpacity); + cancelAnimation(fallbackTranslation); + if (activeTranslationRef.current) cancelAnimation(activeTranslationRef.current); + finishDismiss(); + fullSwipeArmedRef.current = false; + restoreRow(); + }, [actionOpacity, collapse, fallbackTranslation, finishDismiss, props.resetKey, restoreRow]); + const dismiss = useCallback( (translation: SharedValue) => { "worklet"; From b084ccef8c89f094a2b137cc8ef65aab442479ab Mon Sep 17 00:00:00 2001 From: AKolenda Date: Sun, 27 Sep 2026 15:15:57 -0600 Subject: [PATCH 3/8] fix(mobile): reused Home rows drop the previous content's swipe state - A late restore from the previous content's dismissal no longer resets the row once the list has reused it for other content. - A row that goes dormant while open now closes. - Resetting a row that was open also turns off its tap-to-close gesture, which reset() left on and which cancelled the next press on the row. --- .../features/home/thread-swipe-actions.tsx | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 7de249a0116d..5dbc4d155a1a 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -300,10 +300,20 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { const primaryAction = props.primaryAction; const onSwipeableClose = props.onSwipeableClose; + // Tracks the swipeable's open state, which it does not expose. + const openRef = useRef(false); + // Bumped when the row is reused for other content, so a late restore from + // the previous content's dismissal cannot reset the new content. + const contentGenerationRef = useRef(0); + const restoreRow = useCallback(() => { if (!mountedRef.current) return; dismissalRef.current = null; swipeableRef.current?.reset(); + // reset() leaves an open row's tap-to-close gesture on, and that gesture + // cancels the next press on the row. Closing the now-closed row turns it + // off without moving anything. + if (openRef.current) swipeableRef.current?.close(); fallbackTranslation.set(0); collapse.set(0); actionOpacity.set(1); @@ -341,10 +351,18 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { cancelAnimation(fallbackTranslation); if (activeTranslationRef.current) cancelAnimation(activeTranslationRef.current); finishDismiss(); + contentGenerationRef.current += 1; fullSwipeArmedRef.current = false; restoreRow(); }, [actionOpacity, collapse, fallbackTranslation, finishDismiss, props.resetKey, restoreRow]); + // A dormant row has no actions, so it must not stay open. + useLayoutEffect(() => { + if (props.dormant && openRef.current && dismissalRef.current === null) { + swipeableRef.current?.close(); + } + }, [props.dormant]); + const dismiss = useCallback( (translation: SharedValue) => { "worklet"; @@ -381,7 +399,13 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { setIsDismissing(true); if (swipeableRef.current) onSwipeableClose?.(swipeableRef.current); runOnUI(dismiss)(activeTranslationRef.current ?? fallbackTranslation); - dismissalRef.current = { finished, restore: restoreRow }; + const generation = contentGenerationRef.current; + dismissalRef.current = { + finished, + restore: () => { + if (contentGenerationRef.current === generation) restoreRow(); + }, + }; return dismissalRef.current; }), [dismiss, fallbackTranslation, onSwipeableClose, props.threadKey, restoreRow], @@ -439,6 +463,7 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { failOffsetY={[-10, 10]} friction={1} onSwipeableClose={() => { + openRef.current = false; fullSwipeArmedRef.current = false; if (swipeableRef.current) { props.onSwipeableClose?.(swipeableRef.current); @@ -451,6 +476,7 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { } }} onSwipeableWillOpen={() => { + openRef.current = true; const methods = swipeableRef.current; if (!methods) { return; From 089f735ac00b539a17b60b2aeb88d9ae31ae5bfc Mon Sep 17 00:00:00 2001 From: AKolenda Date: Fri, 2 Oct 2026 23:36:46 -0600 Subject: [PATCH 4/8] fix(mobile): dormant Home rows don't hold up thread actions Dormant rows now mount the swipeable row, so they registered for thread dismissal too. Archiving or deleting a thread whose Home row was out of view then waited for an exit animation nobody could see. Register only rows that are awake, as before dormant rows shared the swipeable tree. --- .../features/home/thread-swipe-actions.tsx | 41 ++++++++++--------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 5dbc4d155a1a..7dd868d38b94 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -388,27 +388,30 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { }, [actionOpacity, collapse, dismissing, finishDismiss, rowWidth], ); + // A dormant row is out of view: an exit animation there only delays the action. useLayoutEffect( () => - registerThreadDismissal(props.threadKey, () => { - if (dismissalRef.current) return dismissalRef.current; - const finished = new Promise((resolve) => { - pendingDismissRef.current = resolve; - }); - fullSwipeArmedRef.current = false; - setIsDismissing(true); - if (swipeableRef.current) onSwipeableClose?.(swipeableRef.current); - runOnUI(dismiss)(activeTranslationRef.current ?? fallbackTranslation); - const generation = contentGenerationRef.current; - dismissalRef.current = { - finished, - restore: () => { - if (contentGenerationRef.current === generation) restoreRow(); - }, - }; - return dismissalRef.current; - }), - [dismiss, fallbackTranslation, onSwipeableClose, props.threadKey, restoreRow], + props.dormant + ? undefined + : registerThreadDismissal(props.threadKey, () => { + if (dismissalRef.current) return dismissalRef.current; + const finished = new Promise((resolve) => { + pendingDismissRef.current = resolve; + }); + fullSwipeArmedRef.current = false; + setIsDismissing(true); + if (swipeableRef.current) onSwipeableClose?.(swipeableRef.current); + runOnUI(dismiss)(activeTranslationRef.current ?? fallbackTranslation); + const generation = contentGenerationRef.current; + dismissalRef.current = { + finished, + restore: () => { + if (contentGenerationRef.current === generation) restoreRow(); + }, + }; + return dismissalRef.current; + }), + [dismiss, fallbackTranslation, onSwipeableClose, props.dormant, props.threadKey, restoreRow], ); const dismissStyle = useAnimatedStyle(() => ({ height: dismissing.value ? rowHeight.value * (1 - collapse.value) : undefined, From 3da7225a112a48c3b82bfd6bfeb14b5f419a3a89 Mon Sep 17 00:00:00 2001 From: AKolenda Date: Fri, 2 Oct 2026 23:51:05 -0600 Subject: [PATCH 5/8] fix(mobile): a Home row going dormant mid-swipe still closes The row counted itself open from onSwipeableWillOpen, which reaches JS after the release. A row that went dormant in that gap skipped close() and then finished opening with no actions. Count it open from the start of the drag; onSwipeableClose still clears it when the row snaps back. --- apps/mobile/src/features/home/thread-swipe-actions.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 7dd868d38b94..f7547617998b 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -474,6 +474,9 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { }} onSwipeableRelease={handleRelease} onSwipeableOpenStartDrag={() => { + // WillOpen reaches JS after the release; count the row open from the + // drag so going dormant in between still closes it. + openRef.current = true; if (swipeableRef.current) { props.onSwipeableWillOpen?.(swipeableRef.current); } From bfd569b66f6a0c5a0078f8e8bb3169ff69f53cee Mon Sep 17 00:00:00 2001 From: AKolenda Date: Sat, 3 Oct 2026 00:11:23 -0600 Subject: [PATCH 6/8] fix(mobile): a stale row collapse can't finish a newer dismissal A collapse that finished just before its row was reused could queue finishDismiss, and the reused row's next dismissal would then resolve before its own collapse ran. Give each dismissal an id and ignore completions from older ones. --- .../features/home/thread-swipe-actions.tsx | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index f7547617998b..1ea363db8928 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -288,7 +288,8 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { const gateEnabled = use(SwipeableScrollGateContext); const mountedRef = useRef(true); const dismissalRef = useRef<{ finished: Promise; restore: () => void } | null>(null); - const pendingDismissRef = useRef<(() => void) | null>(null); + const pendingDismissRef = useRef<{ id: number; resolve: () => void } | null>(null); + const dismissalIdRef = useRef(0); const activeTranslationRef = useRef | null>(null); const [isDismissing, setIsDismissing] = useState(false); const dismissing = useSharedValue(false); @@ -321,10 +322,13 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { setIsDismissing(false); }, [actionOpacity, collapse, dismissing, fallbackTranslation]); - const finishDismiss = useCallback(() => { - const finish = pendingDismissRef.current; + // The collapse passes its dismissal id: a completion queued before the row was + // reused must not finish a newer dismissal. + const finishDismiss = useCallback((id?: number) => { + const pending = pendingDismissRef.current; + if (!pending || (id !== undefined && pending.id !== id)) return; pendingDismissRef.current = null; - finish?.(); + pending.resolve(); }, []); useLayoutEffect(() => { @@ -364,7 +368,7 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { }, [props.dormant]); const dismiss = useCallback( - (translation: SharedValue) => { + (translation: SharedValue, id: number) => { "worklet"; if (dismissing.value) return; dismissing.set(true); @@ -380,7 +384,7 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { if (!finished) return; collapse.set( withTiming(1, { ...timing, duration: 180 }, (collapsed) => { - if (collapsed) runOnJS(finishDismiss)(); + if (collapsed) runOnJS(finishDismiss)(id); }), ); }), @@ -395,13 +399,14 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { ? undefined : registerThreadDismissal(props.threadKey, () => { if (dismissalRef.current) return dismissalRef.current; + const id = ++dismissalIdRef.current; const finished = new Promise((resolve) => { - pendingDismissRef.current = resolve; + pendingDismissRef.current = { id, resolve }; }); fullSwipeArmedRef.current = false; setIsDismissing(true); if (swipeableRef.current) onSwipeableClose?.(swipeableRef.current); - runOnUI(dismiss)(activeTranslationRef.current ?? fallbackTranslation); + runOnUI(dismiss)(activeTranslationRef.current ?? fallbackTranslation, id); const generation = contentGenerationRef.current; dismissalRef.current = { finished, From 32b53c1adfd87ccf98e1756f947ef199709e7da4 Mon Sep 17 00:00:00 2001 From: AKolenda Date: Sat, 26 Sep 2026 19:32:28 -0600 Subject: [PATCH 7/8] fix(mobile): Home rows open on the first tap after a fling to either end --- apps/mobile/src/features/home/HomeScreen.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index ce9db75a2a70..61702c4d7954 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -948,6 +948,10 @@ export function HomeScreen(props: HomeScreenProps) { itemsAreEqual={threadListV2ListItemsAreEqual} estimatedItemSize={ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT} drawDistance={THREAD_LIST_V2_DRAW_DISTANCE} + // Android's stretch overscroll claims any touch that lands while it + // springs back, so a tap on a row just after a fling to either end + // did nothing. + overScrollMode="never" recycleItems extraData={v2ExtraData} ListHeaderComponent={v2ListHeader} From 8f64fe956616f3056f7f4eb558fc4ad2d0e4566e Mon Sep 17 00:00:00 2001 From: AKolenda Date: Sun, 27 Sep 2026 13:51:15 -0600 Subject: [PATCH 8/8] fix(mobile): a tap on a Home list that just stopped opens the thread React Native's JS scroll view takes every tap until it hears onMomentumScrollEnd, plus 16 ms. Android sends that three frames after the list stops, later while JS renders the rows a fling revealed, so a tap on a list that had visibly stopped only stopped it. The native scroll view already takes taps during a real fling, so skip the JS check on Android. --- apps/mobile/src/features/home/HomeScreen.tsx | 23 ++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 61702c4d7954..293afb9160dd 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -18,6 +18,8 @@ import { ActivityIndicator, Platform, View, + type ScrollView, + type ScrollViewInstance, type GestureResponderEvent, type NativeScrollEvent, type NativeSyntheticEvent, @@ -139,6 +141,26 @@ const ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT = 72; // rows that are already built instead of rows still being rebuilt. const THREAD_LIST_V2_DRAW_DISTANCE = 1_000; const PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT = 44; + +/** + * React Native's JS scroll view takes every tap while it believes momentum is + * still running: until onMomentumScrollEnd arrives and for 16 ms after it. + * Android sends that event three frames after the list stops, later still + * while JS renders the rows a fling revealed, so a tap on a list that had + * visibly stopped was spent stopping it. The native scroll view already takes + * taps that land during a fling, so Android can skip the JS check. + */ +function letTapsThroughAfterFling(scrollView: ScrollView | null) { + if (Platform.OS !== "android" || scrollView === null) return; + // `_isAnimating` is private to ScrollView.js; if it goes away this no-ops. + // LegendList types this ref as the ScrollView component; it receives the instance. + const instance = scrollView as unknown as ScrollViewInstance; + const responder = instance.getScrollResponder() as unknown as { + _isAnimating?: () => boolean; + }; + if (typeof responder._isAnimating === "function") responder._isAnimating = () => false; +} + /** * Top spacing between the list and the Android custom header. The Android * header is rendered in-flow above this screen and @@ -937,6 +959,7 @@ export function HomeScreen(props: HomeScreenProps) { activateVisibleRows(threadListV2Items)} onTouchStart={(event) => trackListTouches(event, true)} onTouchEnd={(event) => trackListTouches(event, false)}