authorgravatar for akole779@mtroyal.caAKolenda <akole779@mtroyal.ca> 2026-09-25 18:23:19-06:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-25 17:23:19-07:00
log525af2d1ad4b6de847ea658c37a081fd7afe8ef6
tree5494516b467caf85598d877065ebb51a23e4e88f
parented809f7ad274ebccdfbf029ccfd7ff2582fd486a
signature Signed by PGP key B5690EEEBB952194

perf(mobile): lighter Home rows away from the viewport (#13702)


5 files changed, 217 insertions(+), 5 deletions(-)

apps/mobile/src/features/home/HomeScreen.tsx+58-4
......@@ -1,7 +1,7 @@
11import { useAndroidControlSizing } from "../../components/useAndroidControlSizing";
22import type { ThreadMoveDestination } from "../threads/threadOrder";
33import { computeThreadMoveAvailability } from "../threads/threadOrder";
4import { LegendList } from "@legendapp/list/react-native";
4import { LegendList, type LegendListRef } from "@legendapp/list/react-native";
55import {
66 type EnvironmentProject,
77 type EnvironmentThreadShell,
......@@ -18,7 +18,14 @@ import {
1818import { useAtomValue } from "@effect/atom-react";
1919import { useFocusEffect } from "@react-navigation/native";
2020import { useCallback, useEffect, useMemo, useRef, useState } from "react";
21import { ActivityIndicator, Platform, View } from "react-native";
21import {
22 ActivityIndicator,
23 Platform,
24 View,
25 type GestureResponderEvent,
26 type NativeScrollEvent,
27 type NativeSyntheticEvent,
28} from "react-native";
2229import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
2330import { useSafeAreaInsets } from "react-native-safe-area-context";
2431
......@@ -59,6 +66,7 @@ import {
5966 sortHomeProjectScopes,
6067 type HomeProjectSortOrder,
6168} from "./homeThreadList";
69import { createSwipeRowActivation } from "./swipe-row-activation";
6270import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions";
6371import { useMaterialFabScroll } from "./MaterialFabScrollContext";
6472
......@@ -266,8 +274,45 @@ export function HomeScreen(props: HomeScreenProps) {
266274 openSwipeableRef.current?.close();
267275 }, []);
268276 const onMaterialFabScroll = useMaterialFabScroll();
277 const listRef = useRef<LegendListRef>(null);
278 const swipeRowActivation = useMemo(() => createSwipeRowActivation(), []);
279 const activateVisibleRows = useCallback(
280 (rows: ReadonlyArray<ThreadListV2ListItem>) => {
281 const state = listRef.current?.getState();
282 if (state === undefined || !(state.end >= 0)) return;
283 swipeRowActivation.activate(
284 rows.slice(Math.max(0, state.start - 2), state.end + 3).map((row) => row.key),
285 );
286 },
287 [swipeRowActivation],
288 );
289 // Status-bar, accessibility and programmatic scrolls never arm the scroll
290 // gate, so every scroll also activates the visible rows once it settles.
291 const activationTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
292 useEffect(() => () => clearTimeout(activationTimerRef.current), []);
293 const handleListScroll = useCallback(
294 (event: NativeSyntheticEvent<NativeScrollEvent>) => {
295 onMaterialFabScroll?.(event);
296 clearTimeout(activationTimerRef.current);
297 activationTimerRef.current = setTimeout(
298 () => activateVisibleRows(listRef.current?.getState().data ?? []),
299 200,
300 );
301 },
302 [activateVisibleRows, onMaterialFabScroll],
303 );
304 const trackListTouches = useCallback(
305 (event: GestureResponderEvent, started: boolean) => {
306 const { changedTouches, touches } = event.nativeEvent;
307 swipeRowActivation.trackTouches(
308 started ? changedTouches.map((touch) => touch.identifier) : [],
309 touches.map((touch) => touch.identifier),
310 );
311 },
312 [swipeRowActivation],
313 );
269314 const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({
270 onScroll: onMaterialFabScroll,
315 onScroll: handleListScroll,
271316 onScrollBeginDrag: handleScrollBeginDrag,
272317 });
273318
......@@ -657,6 +702,9 @@ export function HomeScreen(props: HomeScreenProps) {
657702 );
658703
659704 useThreadJumpShortcuts(threadListV2Items, props.onSelectThread);
705 useEffect(() => {
706 if (swipeEnabled) activateVisibleRows(threadListV2Items);
707 }, [activateVisibleRows, swipeEnabled, threadListV2Items]);
660708
661709 const renderV2Item = useCallback(
662710 ({ item }: { readonly item: ThreadListV2ListItem }) => {
......@@ -764,6 +812,7 @@ export function HomeScreen(props: HomeScreenProps) {
764812 onMoveThread={handleMoveThread}
765813 onSwipeableClose={handleSwipeableClose}
766814 onSwipeableWillOpen={handleSwipeableWillOpen}
815 activationKey={item.key}
767816 />
768817 );
769818 },
......@@ -952,8 +1001,13 @@ export function HomeScreen(props: HomeScreenProps) {
9521001 {/* Shared with the iPad sidebar: cells are reused across data
9531002 rebuilds and `itemsAreEqual` keeps a minute tick (or an unrelated
9541003 shell update) from re-rendering untouched rows. */}
955 <SwipeableScrollGateProvider enabled={swipeEnabled}>
1004 <SwipeableScrollGateProvider enabled={swipeEnabled} activation={swipeRowActivation}>
9561005 <LegendList
1006 ref={listRef}
1007 onLoad={() => activateVisibleRows(threadListV2Items)}
1008 onTouchStart={(event) => trackListTouches(event, true)}
1009 onTouchEnd={(event) => trackListTouches(event, false)}
1010 onTouchCancel={(event) => trackListTouches(event, false)}
9571011 data={threadListV2Items}
9581012 renderItem={renderV2Item}
9591013 keyExtractor={v2KeyExtractor}
apps/mobile/src/features/home/swipe-row-activation.test.ts created+68
......@@ -0,0 +1,68 @@
1import { describe, expect, it, vi } from "vite-plus/test";
2
3import { createSwipeRowActivation } from "./swipe-row-activation";
4
5describe("createSwipeRowActivation", () => {
6 it("activates exactly the requested rows and notifies only on change", () => {
7 const activation = createSwipeRowActivation();
8 const listener = vi.fn();
9 activation.subscribe(listener);
10
11 activation.activate(["a", "b"]);
12 activation.activate(["b", "a"]);
13
14 expect(activation.isActive("a")).toBe(true);
15 expect(activation.isActive("c")).toBe(false);
16 expect(listener).toHaveBeenCalledTimes(1);
17
18 activation.activate(["c"]);
19 expect(activation.isActive("a")).toBe(false);
20 expect(activation.isActive("c")).toBe(true);
21 expect(listener).toHaveBeenCalledTimes(2);
22 });
23
24 it("defers changes while a finger is on the list so a press is never remounted", () => {
25 const activation = createSwipeRowActivation();
26 activation.activate(["a"]);
27
28 activation.trackTouches(["1"], ["1"]);
29 activation.activate(["b"]);
30 activation.activate(["c"]);
31 expect(activation.isActive("a")).toBe(true);
32 expect(activation.isActive("c")).toBe(false);
33
34 activation.trackTouches([], []);
35 expect(activation.isActive("a")).toBe(false);
36 expect(activation.isActive("b")).toBe(false);
37 expect(activation.isActive("c")).toBe(true);
38 });
39
40 it("ignores fingers that did not start on the list", () => {
41 const activation = createSwipeRowActivation();
42 activation.trackTouches(["1"], ["1", "2"]);
43 activation.activate(["a"]);
44
45 // The list finger lifts while finger 2 stays on another control.
46 activation.trackTouches([], ["2"]);
47 expect(activation.isActive("a")).toBe(true);
48 });
49
50 it("drops a list finger whose end event never arrived", () => {
51 const activation = createSwipeRowActivation();
52 activation.trackTouches(["1"], ["1"]);
53 activation.activate(["a"]);
54
55 activation.trackTouches(["2"], ["2"]);
56 activation.trackTouches([], []);
57 expect(activation.isActive("a")).toBe(true);
58 });
59
60 it("stops notifying after unsubscribe", () => {
61 const activation = createSwipeRowActivation();
62 const listener = vi.fn();
63 const unsubscribe = activation.subscribe(listener);
64 unsubscribe();
65 activation.activate(["a"]);
66 expect(listener).not.toHaveBeenCalled();
67 });
68});
apps/mobile/src/features/home/swipe-row-activation.ts created+62
......@@ -0,0 +1,62 @@
1import { createContext, use, useSyncExternalStore } from "react";
2
3/**
4 * Full swipe rows (pan gesture, animated actions, hidden action buttons) only
5 * exist around the viewport. Every other Home row renders a dormant frame that
6 * paints the same content with a fraction of the native views, so a row the
7 * list rebuilds while scrolling is cheap. The scroll gate already disables
8 * swipes while the list moves, so rows are activated once it rests.
9 */
10export function createSwipeRowActivation() {
11 let activeKeys = new Set<string>();
12 // Swapping a row's frame remounts it, which would cancel a press or long
13 // press in progress, so changes wait until every finger that started on the
14 // list has lifted.
15 const listTouches = new Set<string>();
16 let pendingKeys: ReadonlyArray<string> | null = null;
17 const listeners = new Set<() => void>();
18 const apply = (keys: ReadonlyArray<string>) => {
19 if (keys.length === activeKeys.size && keys.every((key) => activeKeys.has(key))) return;
20 activeKeys = new Set(keys);
21 for (const listener of listeners) listener();
22 };
23 return {
24 subscribe(listener: () => void) {
25 listeners.add(listener);
26 return () => void listeners.delete(listener);
27 },
28 isActive: (key: string) => activeKeys.has(key),
29 activate(keys: ReadonlyArray<string>) {
30 if (listTouches.size > 0) pendingKeys = keys;
31 else apply(keys);
32 },
33 /**
34 * `started` are touches that just began on the list; `onScreen` is every
35 * finger still down anywhere. A finger on another control never holds
36 * changes, and one whose end event went missing is dropped here.
37 */
38 trackTouches(started: ReadonlyArray<string>, onScreen: ReadonlyArray<string>) {
39 for (const id of started) listTouches.add(id);
40 for (const id of listTouches) if (!onScreen.includes(id)) listTouches.delete(id);
41 if (listTouches.size > 0 || pendingKeys === null) return;
42 const keys = pendingKeys;
43 pendingKeys = null;
44 apply(keys);
45 },
46 };
47}
48
49export type SwipeRowActivation = ReturnType<typeof createSwipeRowActivation>;
50
51export const SwipeRowActivationContext = createContext<SwipeRowActivation | null>(null);
52
53const subscribeNever = () => () => {};
54
55/** Rows outside an activation provider (e.g. the iPad sidebar) stay live. */
56export function useSwipeRowDormant(key: string | undefined): boolean {
57 const activation = use(SwipeRowActivationContext);
58 return useSyncExternalStore(
59 activation?.subscribe ?? subscribeNever,
60 () => activation !== null && key !== undefined && !activation.isActive(key),
61 );
62}
apps/mobile/src/features/home/thread-swipe-actions.tsx+24-1
......@@ -40,6 +40,7 @@ import Animated, {
4040} from "react-native-reanimated";
4141
4242import { AppText as Text } from "../../components/AppText";
43import { SwipeRowActivationContext, type SwipeRowActivation } from "./swipe-row-activation";
4344import { registerThreadDismissal } from "./thread-dismissal";
4445
4546// Wide enough for the longest action label ("Unarchive").
......@@ -129,11 +130,14 @@ const SwipeableScrollGateContext = createContext(true);
129130
130131export function SwipeableScrollGateProvider(props: {
131132 readonly enabled: boolean;
133 readonly activation?: SwipeRowActivation;
132134 readonly children: ReactNode;
133135}) {
134136 return (
135137 <SwipeableScrollGateContext.Provider value={props.enabled}>
136 {props.children}
138 <SwipeRowActivationContext value={props.activation ?? null}>
139 {props.children}
140 </SwipeRowActivationContext>
137141 </SwipeableScrollGateContext.Provider>
138142 );
139143}
......@@ -260,13 +264,32 @@ interface ThreadSwipeableProps {
260264 * open/mid-drag state can't leak onto another row.
261265 */
262266 readonly resetKey?: string;
267 /** Paints the row without swipe machinery; see swipe-row-activation. */
268 readonly dormant?: boolean;
263269 readonly simultaneousWithExternalGesture?: ComponentProps<
264270 typeof ReanimatedSwipeable
265271 >["simultaneousWithExternalGesture"];
266272 readonly threadTitle: string;
267273}
268274
275const closeDormant = () => {};
276
269277export function ThreadSwipeable(props: ThreadSwipeableProps) {
278 if (props.dormant) {
279 // Mirrors ReanimatedSwipeable's container and children views.
280 return (
281 <View
282 style={[
283 { overflow: "hidden", backgroundColor: props.backgroundColor },
284 props.containerStyle,
285 ]}
286 >
287 <View style={{ backgroundColor: props.backgroundColor }}>
288 {props.children(closeDormant)}
289 </View>
290 </View>
291 );
292 }
270293 // Recycled content gets fresh native and animation state. Late callbacks
271294 // from the previous row retain its action, never the replacement's action.
272295 return <ThreadSwipeableRow key={props.resetKey} {...props} />;
apps/mobile/src/features/threads/thread-list-v2-items.tsx+5
......@@ -35,6 +35,7 @@ import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
3535import { useUniwindTheme } from "../../lib/useUniwindTheme";
3636import type { PendingNewTask } from "../../state/use-pending-new-tasks";
3737import { useThreadPr } from "../../state/use-thread-pr";
38import { useSwipeRowDormant } from "../home/swipe-row-activation";
3839import { ThreadSwipeable } from "../home/thread-swipe-actions";
3940import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
4041import {
......@@ -518,6 +519,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
518519 readonly canMoveDown?: boolean;
519520 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
520521 readonly onSwipeableClose: (methods: SwipeableMethods) => void;
522 /** List key checked against the Home swipe row activation. */
523 readonly activationKey?: string;
521524 readonly searchMatch?: EnvironmentThreadSearchMatch;
522525 readonly searchQuery?: string;
523526 readonly simultaneousSwipeGesture?: ComponentProps<
......@@ -545,6 +548,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
545548 } = props;
546549 const snoozedRow = props.snoozed === true;
547550 const pinnedRow = props.pinned === true;
551 const dormant = useSwipeRowDormant(props.activationKey);
548552
549553 const pr = useThreadPr(thread);
550554
......@@ -1180,6 +1184,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
11801184 <CustomSnoozeSheet onClose={() => setCustomSnoozeOpen(false)} onSnooze={handleSnooze} />
11811185 )}
11821186 <ThreadSwipeable
1187 dormant={dormant}
11831188 threadKey={`${thread.environmentId}:${thread.id}`}
11841189 backgroundColor={rowAppearance.swipeBackgroundColor}
11851190 compactActions={variant === "slim"}