From b2ac57bdfafbddef0a5cb3a0363b6bc90757de2f Mon Sep 17 00:00:00 2001 From: AKolenda Date: Thu, 24 Sep 2026 19:22:11 -0600 Subject: [PATCH 1/2] perf(mobile): give only Home rows near the viewport their swipe machinery Rows away from the viewport render a dormant frame with the same content, tap and long-press menu, but no pan gesture, Reanimated styles or hidden action buttons. Visible rows become swipeable once scrolling settles, and activation waits until no finger is on the list. --- apps/mobile/src/features/home/HomeScreen.tsx | 55 +++++++++++++++++-- .../home/swipe-row-activation.test.ts | 48 ++++++++++++++++ .../src/features/home/swipe-row-activation.ts | 55 +++++++++++++++++++ .../features/home/thread-swipe-actions.tsx | 25 ++++++++- .../features/threads/thread-list-v2-items.tsx | 5 ++ 5 files changed, 183 insertions(+), 5 deletions(-) create mode 100644 apps/mobile/src/features/home/swipe-row-activation.test.ts create mode 100644 apps/mobile/src/features/home/swipe-row-activation.ts diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index d1d108d3bed0..ed7fac195f43 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -1,7 +1,7 @@ import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; import type { ThreadMoveDestination } from "../threads/threadOrder"; import { computeThreadMoveAvailability } from "../threads/threadOrder"; -import { LegendList } from "@legendapp/list/react-native"; +import { LegendList, type LegendListRef } from "@legendapp/list/react-native"; import { type EnvironmentProject, type EnvironmentThreadShell, @@ -18,7 +18,13 @@ import { import { useAtomValue } from "@effect/atom-react"; import { useFocusEffect } from "@react-navigation/native"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ActivityIndicator, Platform, View } from "react-native"; +import { + ActivityIndicator, + Platform, + View, + type NativeScrollEvent, + type NativeSyntheticEvent, +} from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -59,6 +65,7 @@ import { sortHomeProjectScopes, type HomeProjectSortOrder, } from "./homeThreadList"; +import { createSwipeRowActivation } from "./swipe-row-activation"; import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions"; import { useMaterialFabScroll } from "./MaterialFabScrollContext"; @@ -266,8 +273,35 @@ export function HomeScreen(props: HomeScreenProps) { openSwipeableRef.current?.close(); }, []); const onMaterialFabScroll = useMaterialFabScroll(); + const listRef = useRef(null); + const swipeRowActivation = useMemo(() => createSwipeRowActivation(), []); + const activateVisibleRows = useCallback( + (rows: ReadonlyArray) => { + const state = listRef.current?.getState(); + if (state === undefined || !(state.end >= 0)) return; + swipeRowActivation.activate( + rows.slice(Math.max(0, state.start - 2), state.end + 3).map((row) => row.key), + ); + }, + [swipeRowActivation], + ); + // Status-bar, accessibility and programmatic scrolls never arm the scroll + // gate, so every scroll also activates the visible rows once it settles. + const activationTimerRef = useRef | undefined>(undefined); + useEffect(() => () => clearTimeout(activationTimerRef.current), []); + const handleListScroll = useCallback( + (event: NativeSyntheticEvent) => { + onMaterialFabScroll?.(event); + clearTimeout(activationTimerRef.current); + activationTimerRef.current = setTimeout( + () => activateVisibleRows(listRef.current?.getState().data ?? []), + 200, + ); + }, + [activateVisibleRows, onMaterialFabScroll], + ); const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({ - onScroll: onMaterialFabScroll, + onScroll: handleListScroll, onScrollBeginDrag: handleScrollBeginDrag, }); @@ -657,6 +691,9 @@ export function HomeScreen(props: HomeScreenProps) { ); useThreadJumpShortcuts(threadListV2Items, props.onSelectThread); + useEffect(() => { + if (swipeEnabled) activateVisibleRows(threadListV2Items); + }, [activateVisibleRows, swipeEnabled, threadListV2Items]); const renderV2Item = useCallback( ({ item }: { readonly item: ThreadListV2ListItem }) => { @@ -764,6 +801,7 @@ export function HomeScreen(props: HomeScreenProps) { onMoveThread={handleMoveThread} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} + activationKey={item.key} /> ); }, @@ -952,8 +990,17 @@ export function HomeScreen(props: HomeScreenProps) { {/* Shared with the iPad sidebar: cells are reused across data rebuilds and `itemsAreEqual` keeps a minute tick (or an unrelated shell update) from re-rendering untouched rows. */} - + activateVisibleRows(threadListV2Items)} + onTouchStart={() => swipeRowActivation.setTouching(true)} + onTouchEnd={(event) => + swipeRowActivation.setTouching(event.nativeEvent.touches.length > 0) + } + onTouchCancel={(event) => + swipeRowActivation.setTouching(event.nativeEvent.touches.length > 0) + } data={threadListV2Items} renderItem={renderV2Item} keyExtractor={v2KeyExtractor} diff --git a/apps/mobile/src/features/home/swipe-row-activation.test.ts b/apps/mobile/src/features/home/swipe-row-activation.test.ts new file mode 100644 index 000000000000..b0c39e1bb051 --- /dev/null +++ b/apps/mobile/src/features/home/swipe-row-activation.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { createSwipeRowActivation } from "./swipe-row-activation"; + +describe("createSwipeRowActivation", () => { + it("activates exactly the requested rows and notifies only on change", () => { + const activation = createSwipeRowActivation(); + const listener = vi.fn(); + activation.subscribe(listener); + + activation.activate(["a", "b"]); + activation.activate(["b", "a"]); + + expect(activation.isActive("a")).toBe(true); + expect(activation.isActive("c")).toBe(false); + expect(listener).toHaveBeenCalledTimes(1); + + activation.activate(["c"]); + expect(activation.isActive("a")).toBe(false); + expect(activation.isActive("c")).toBe(true); + expect(listener).toHaveBeenCalledTimes(2); + }); + + it("defers changes while a finger is down so a press is never remounted", () => { + const activation = createSwipeRowActivation(); + activation.activate(["a"]); + + activation.setTouching(true); + activation.activate(["b"]); + activation.activate(["c"]); + expect(activation.isActive("a")).toBe(true); + expect(activation.isActive("c")).toBe(false); + + activation.setTouching(false); + expect(activation.isActive("a")).toBe(false); + expect(activation.isActive("b")).toBe(false); + expect(activation.isActive("c")).toBe(true); + }); + + it("stops notifying after unsubscribe", () => { + const activation = createSwipeRowActivation(); + const listener = vi.fn(); + const unsubscribe = activation.subscribe(listener); + unsubscribe(); + activation.activate(["a"]); + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/features/home/swipe-row-activation.ts b/apps/mobile/src/features/home/swipe-row-activation.ts new file mode 100644 index 000000000000..6141c117beb2 --- /dev/null +++ b/apps/mobile/src/features/home/swipe-row-activation.ts @@ -0,0 +1,55 @@ +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 + * 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 for the finger to lift. + let touching = false; + let pendingKeys: ReadonlyArray | null = null; + const listeners = new Set<() => void>(); + const apply = (keys: ReadonlyArray) => { + if (keys.length === activeKeys.size && keys.every((key) => activeKeys.has(key))) return; + activeKeys = new Set(keys); + for (const listener of listeners) listener(); + }; + return { + subscribe(listener: () => void) { + listeners.add(listener); + return () => void listeners.delete(listener); + }, + isActive: (key: string) => activeKeys.has(key), + activate(keys: ReadonlyArray) { + if (touching) pendingKeys = keys; + else apply(keys); + }, + setTouching(next: boolean) { + touching = next; + if (next || pendingKeys === null) return; + const keys = pendingKeys; + pendingKeys = null; + apply(keys); + }, + }; +} + +export type SwipeRowActivation = ReturnType; + +export const SwipeRowActivationContext = createContext(null); + +const subscribeNever = () => () => {}; + +/** Rows outside an activation provider (e.g. the iPad sidebar) stay live. */ +export function useSwipeRowDormant(key: string | undefined): boolean { + const activation = use(SwipeRowActivationContext); + return useSyncExternalStore( + activation?.subscribe ?? subscribeNever, + () => activation !== null && key !== undefined && !activation.isActive(key), + ); +} diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 44f3c36e6655..e62935c28861 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -40,6 +40,7 @@ import Animated, { } from "react-native-reanimated"; import { AppText as Text } from "../../components/AppText"; +import { SwipeRowActivationContext, type SwipeRowActivation } from "./swipe-row-activation"; import { registerThreadDismissal } from "./thread-dismissal"; // Wide enough for the longest action label ("Unarchive"). @@ -129,11 +130,14 @@ const SwipeableScrollGateContext = createContext(true); export function SwipeableScrollGateProvider(props: { readonly enabled: boolean; + readonly activation?: SwipeRowActivation; readonly children: ReactNode; }) { return ( - {props.children} + + {props.children} + ); } @@ -260,13 +264,32 @@ 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. */ + readonly dormant?: boolean; readonly simultaneousWithExternalGesture?: ComponentProps< typeof ReanimatedSwipeable >["simultaneousWithExternalGesture"]; 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 ; diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 31039388dc09..eb089b95060a 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -35,6 +35,7 @@ import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useThreadPr } from "../../state/use-thread-pr"; +import { useSwipeRowDormant } from "../home/swipe-row-activation"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; import { @@ -518,6 +519,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly canMoveDown?: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; + /** List key checked against the Home swipe row activation. */ + readonly activationKey?: string; readonly searchMatch?: EnvironmentThreadSearchMatch; readonly searchQuery?: string; readonly simultaneousSwipeGesture?: ComponentProps< @@ -545,6 +548,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { } = props; const snoozedRow = props.snoozed === true; const pinnedRow = props.pinned === true; + const dormant = useSwipeRowDormant(props.activationKey); const pr = useThreadPr(thread); @@ -1180,6 +1184,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { setCustomSnoozeOpen(false)} onSnooze={handleSnooze} /> )} Date: Fri, 25 Sep 2026 15:23:00 -0600 Subject: [PATCH 2/2] fix(mobile): only fingers that started on the Home list hold row activation `touches` counts every finger on screen, so a finger resting on another control kept activation deferred after the list touch ended. Track the list's own touch identifiers, pruned to the fingers still down. --- apps/mobile/src/features/home/HomeScreen.tsx | 21 ++++++++++----- .../home/swipe-row-activation.test.ts | 26 ++++++++++++++++--- .../src/features/home/swipe-row-activation.ts | 19 +++++++++----- 3 files changed, 50 insertions(+), 16 deletions(-) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index ed7fac195f43..c57ac15b1ee5 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -22,6 +22,7 @@ import { ActivityIndicator, Platform, View, + type GestureResponderEvent, type NativeScrollEvent, type NativeSyntheticEvent, } from "react-native"; @@ -300,6 +301,16 @@ export function HomeScreen(props: HomeScreenProps) { }, [activateVisibleRows, onMaterialFabScroll], ); + const trackListTouches = useCallback( + (event: GestureResponderEvent, started: boolean) => { + const { changedTouches, touches } = event.nativeEvent; + swipeRowActivation.trackTouches( + started ? changedTouches.map((touch) => touch.identifier) : [], + touches.map((touch) => touch.identifier), + ); + }, + [swipeRowActivation], + ); const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({ onScroll: handleListScroll, onScrollBeginDrag: handleScrollBeginDrag, @@ -994,13 +1005,9 @@ export function HomeScreen(props: HomeScreenProps) { activateVisibleRows(threadListV2Items)} - onTouchStart={() => swipeRowActivation.setTouching(true)} - onTouchEnd={(event) => - swipeRowActivation.setTouching(event.nativeEvent.touches.length > 0) - } - onTouchCancel={(event) => - swipeRowActivation.setTouching(event.nativeEvent.touches.length > 0) - } + onTouchStart={(event) => trackListTouches(event, true)} + onTouchEnd={(event) => trackListTouches(event, false)} + onTouchCancel={(event) => trackListTouches(event, false)} data={threadListV2Items} renderItem={renderV2Item} keyExtractor={v2KeyExtractor} diff --git a/apps/mobile/src/features/home/swipe-row-activation.test.ts b/apps/mobile/src/features/home/swipe-row-activation.test.ts index b0c39e1bb051..43f8dcf0ca7e 100644 --- a/apps/mobile/src/features/home/swipe-row-activation.test.ts +++ b/apps/mobile/src/features/home/swipe-row-activation.test.ts @@ -21,22 +21,42 @@ describe("createSwipeRowActivation", () => { expect(listener).toHaveBeenCalledTimes(2); }); - it("defers changes while a finger is down so a press is never remounted", () => { + it("defers changes while a finger is on the list so a press is never remounted", () => { const activation = createSwipeRowActivation(); activation.activate(["a"]); - activation.setTouching(true); + activation.trackTouches(["1"], ["1"]); activation.activate(["b"]); activation.activate(["c"]); expect(activation.isActive("a")).toBe(true); expect(activation.isActive("c")).toBe(false); - activation.setTouching(false); + activation.trackTouches([], []); expect(activation.isActive("a")).toBe(false); expect(activation.isActive("b")).toBe(false); expect(activation.isActive("c")).toBe(true); }); + it("ignores fingers that did not start on the list", () => { + const activation = createSwipeRowActivation(); + activation.trackTouches(["1"], ["1", "2"]); + activation.activate(["a"]); + + // The list finger lifts while finger 2 stays on another control. + activation.trackTouches([], ["2"]); + expect(activation.isActive("a")).toBe(true); + }); + + it("drops a list finger whose end event never arrived", () => { + const activation = createSwipeRowActivation(); + activation.trackTouches(["1"], ["1"]); + activation.activate(["a"]); + + activation.trackTouches(["2"], ["2"]); + activation.trackTouches([], []); + expect(activation.isActive("a")).toBe(true); + }); + it("stops notifying after unsubscribe", () => { const activation = createSwipeRowActivation(); const listener = vi.fn(); diff --git a/apps/mobile/src/features/home/swipe-row-activation.ts b/apps/mobile/src/features/home/swipe-row-activation.ts index 6141c117beb2..d56d12cba56f 100644 --- a/apps/mobile/src/features/home/swipe-row-activation.ts +++ b/apps/mobile/src/features/home/swipe-row-activation.ts @@ -10,8 +10,9 @@ import { createContext, use, useSyncExternalStore } from "react"; 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 for the finger to lift. - let touching = false; + // press 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>(); const apply = (keys: ReadonlyArray) => { @@ -26,12 +27,18 @@ export function createSwipeRowActivation() { }, isActive: (key: string) => activeKeys.has(key), activate(keys: ReadonlyArray) { - if (touching) pendingKeys = keys; + if (listTouches.size > 0) pendingKeys = keys; else apply(keys); }, - setTouching(next: boolean) { - touching = next; - if (next || pendingKeys === null) return; + /** + * `started` are touches that just began on the list; `onScreen` is every + * finger still down anywhere. A finger on another control never holds + * changes, and one whose end event went missing is dropped here. + */ + trackTouches(started: ReadonlyArray, onScreen: ReadonlyArray) { + for (const id of started) listTouches.add(id); + for (const id of listTouches) if (!onScreen.includes(id)) listTouches.delete(id); + if (listTouches.size > 0 || pendingKeys === null) return; const keys = pendingKeys; pendingKeys = null; apply(keys);