From 34bdf418cedcd628d97fe8c375112a4c5ac057ff Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:56:29 +0000 Subject: [PATCH] perf(mobile): trim Home and host list re-renders (#39076) --- mobile/src/home/MobileHomeJumpBackIn.tsx | 20 +- mobile/src/home/MobileHomeScreen.tsx | 55 ++-- .../src/host-screen/host-workspace-list.tsx | 234 +++++++++++------- 3 files changed, 188 insertions(+), 121 deletions(-) diff --git a/mobile/src/home/MobileHomeJumpBackIn.tsx b/mobile/src/home/MobileHomeJumpBackIn.tsx index d2ca64279a2..5d3e101ab47 100644 --- a/mobile/src/home/MobileHomeJumpBackIn.tsx +++ b/mobile/src/home/MobileHomeJumpBackIn.tsx @@ -1,5 +1,5 @@ import { ChevronRight } from 'lucide-react-native' -import { useState } from 'react' +import { memo, useMemo, useState } from 'react' import { Pressable, Text, View } from 'react-native' import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated' import type { WorktreeStatus } from '../../../src/shared/worktree-status-rollup' @@ -9,6 +9,7 @@ import { useReducedMotionEnabled } from '../platform/use-reduced-motion' import { colors } from '../theme/mobile-theme' import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' import { useHostDisplay } from '../transport/use-host-display' +import { areStructurallyEqual } from '../worktree/structural-equality' import type { HostCatalogEntry } from '../transport/types' import type { HomeJumpBackInRow } from '../worktree/home-jump-back-in' import { agentStateLabel, type AgentDotState } from '../worktree/agent-row-display' @@ -30,7 +31,8 @@ const TINTED_STATUSES: ReadonlySet = new Set(['permission', 'fai // Enough to see what needs you without the list pushing Desktops off the first screen. const AGENT_ROWS_COLLAPSED = 5 -export function MobileHomeJumpBackIn(props: { +// Usage, stats and connection ticks re-render Home; the section redraws only when its rows change. +export const MobileHomeJumpBackIn = memo(function MobileHomeJumpBackIn(props: { rows: readonly HomeJumpBackInRow[] hosts: readonly HostCatalogEntry[] first: boolean @@ -40,6 +42,11 @@ export function MobileHomeJumpBackIn(props: { useMobileColorScheme() const reducedMotion = useReducedMotionEnabled() === true const [expanded, setExpanded] = useState(false) + // Rows glide to their new place when an agent's state reorders the list. + const layout = useMemo( + () => (reducedMotion ? undefined : LinearTransition.duration(220).easing(HOME_EASE_OUT)), + [reducedMotion] + ) if (props.rows.length === 0) { return null } @@ -48,8 +55,6 @@ export function MobileHomeJumpBackIn(props: { const collapsible = props.rows.length > AGENT_ROWS_COLLAPSED const shown = expanded || !collapsible ? props.rows : props.rows.slice(0, AGENT_ROWS_COLLAPSED) const activeCount = props.rows.filter((row) => !row.lastOpened).length - // Rows glide to their new place when an agent's state reorders the list. - const layout = reducedMotion ? undefined : LinearTransition.duration(220).easing(HOME_EASE_OUT) const entering = reducedMotion ? undefined : FadeIn.duration(180) return ( <> @@ -99,9 +104,10 @@ export function MobileHomeJumpBackIn(props: { ) -} +}) -function JumpBackInRow(props: { +// Each refresh rebuilds every row object; compare by value so only changed rows redraw. +const JumpBackInRow = memo(function JumpBackInRow(props: { row: HomeJumpBackInRow host: HostCatalogEntry | null nameDesktop: boolean @@ -162,4 +168,4 @@ function JumpBackInRow(props: { ) -} +}, areStructurallyEqual) diff --git a/mobile/src/home/MobileHomeScreen.tsx b/mobile/src/home/MobileHomeScreen.tsx index f7ad8e2e9e1..8fa8de3ad88 100644 --- a/mobile/src/home/MobileHomeScreen.tsx +++ b/mobile/src/home/MobileHomeScreen.tsx @@ -83,10 +83,12 @@ export function MobileHomeScreen() { }, [selectTab, tab]) ) + // Stable handlers keep the memoized Feed sections and desktop rows from redrawing on every Home update. + const { router, setHostCatalog } = data const openWorktree = useCallback( (row: HomeJumpBackInRow) => { if (isResumeTargetConfirmedMissing(row, getProvenCachedWorktrees(row.hostId))) { - data.router.push(hostRouteWithNotice(row.hostId, 'worktree-missing')) + router.push(hostRouteWithNotice(row.hostId, 'worktree-missing')) return } openMobileSession({ @@ -96,33 +98,39 @@ export function MobileHomeScreen() { paneKey: row.worktree.paneKey }) }, - [data.router, openMobileSession] + [router, openMobileSession] ) - function openHost(host: HostCatalogEntry): void { - if (host.credentialStatus === 'missing') { - data.router.push('/pair-scan') - } else if (host.credentialStatus === 'temporarily-unavailable') { - void loadHostCatalog() - .then(data.setHostCatalog) - .catch(() => - Alert.alert( - t('home.screen.pairingCheckFailed', 'Could not check pairing'), - t('home.screen.tryAgain', 'Please try again.') + const openHost = useCallback( + (host: HostCatalogEntry) => { + if (host.credentialStatus === 'missing') { + router.push('/pair-scan') + } else if (host.credentialStatus === 'temporarily-unavailable') { + void loadHostCatalog() + .then(setHostCatalog) + .catch(() => + Alert.alert( + t('home.screen.pairingCheckFailed', 'Could not check pairing'), + t('home.screen.tryAgain', 'Please try again.') + ) ) - ) - } else { - data.router.push(`/h/${host.id}`) - } - } + } else { + router.push(`/h/${host.id}`) + } + }, + [router, setHostCatalog, t] + ) - function openHostActions(host: HostCatalogEntry): void { + const openHostActions = useCallback((host: HostCatalogEntry) => { + triggerMediumImpact() if (host.profile) { setActionTarget(host.profile) } else { setConfirmRemove(host) } - } + }, []) + + const openTasks = useCallback((hostId: string) => openMobileTasks(hostId), [openMobileTasks]) async function handleRemove(): Promise { if (!confirmRemove) { @@ -142,7 +150,7 @@ export function MobileHomeScreen() { } } - const pairDesktop = () => data.router.push('/pair-scan') + const pairDesktop = useCallback(() => router.push('/pair-scan'), [router]) const actionTargetPath = actionTarget ? data.hostConnections[actionTarget.id]?.path : undefined const actionTargetConnected = actionTarget != null && data.hostStates[actionTarget.id] === 'connected' @@ -188,12 +196,9 @@ export function MobileHomeScreen() { bottomInset={contentBottom} onOpenWorktree={openWorktree} onOpenHost={openHost} - onOpenHostActions={(host) => { - triggerMediumImpact() - openHostActions(host) - }} + onOpenHostActions={openHostActions} onPairDesktop={pairDesktop} - onOpenTasks={(hostId) => openMobileTasks(hostId)} + onOpenTasks={openTasks} /> ) : id === 'usage' ? ( getWorktreeCardDetails(worktreeCardProperties, { compactWorktreeCards, newCardStyle }), [worktreeCardProperties, compactWorktreeCards, newCardStyle] ) + const sectionCounts = useMemo( + () => new Map(rawSections.map((section) => [section.key, section.data.length])), + [rawSections] + ) + const { collapsedGroups } = controller + const { groupBy, agentActivityDisplayMode } = state.view + const { toggleCollapsed, toggleWorktreeLineage } = settings + const { setActionTarget } = state + const { openWorktreeSession } = actions + const statusLive = connState === 'connected' + + const renderSectionHeader = useCallback( + ({ section }: { section: Section }) => { + const collapseKey = section.collapseKey ?? section.key + return ( + + ) + }, + [collapsedGroups, sectionCounts, pendingCollapseKeys, groupBy, repoLookById, toggleCollapsed] + ) + + const renderItem = useCallback( + ({ item: { row: item, view } }: { item: WorkspaceListItem }) => ( + + ), + [ + isReadOnly, + cardDetails, + now, + repoLookById, + groupBy, + agentActivityDisplayMode, + openWorktreeSession, + statusLive, + setActionTarget, + toggleWorktreeLineage + ] + ) // Group headers can exist before any row loads; the state then sits under them, not above the title. const listState = @@ -199,72 +268,7 @@ export function HostWorkspaceList({ isWideLayout && !embedded && { maxWidth: contentMaxWidth, width: '100%', alignSelf: 'center' } ]} - renderSectionHeader={({ section }) => { - const pendingCreations = section.pendingCreations?.map((creation) => ( - - )) - if (!section.title) { - return pendingCreations ? <>{pendingCreations} : null - } - const collapseKey = section.collapseKey ?? section.key - const isCollapsed = controller.collapsedGroups.has(collapseKey) - const count = - section.count ?? rawSections.find((s) => s.key === section.key)?.data.length ?? 0 - const repoSectionLook = - state.view.groupBy === 'repo' && section.repo - ? repoLookById.get(section.repo.id) - : null - const repoSectionColor = repoSectionLook?.color ?? null - const repoSectionIcon = repoSectionLook?.icon ?? null - return ( - <> - [ - styles.sectionHeader, - section.projectGroupDepth - ? { - paddingLeft: - WORKTREE_ROW_LAYOUT.indicatorInset + - projectGroupHeaderIndent(section.projectGroupDepth) - } - : null, - pressed && { backgroundColor: colors.translucentFill } - ]} - onPress={() => settings.toggleCollapsed(collapseKey)} - > - - - - - - {section.icon === 'pin' && ( - - )} - {state.view.groupBy === 'repo' && section.isProjectGroup ? ( - - ) : state.view.groupBy === 'repo' ? ( - - - - ) : null} - {section.title} - {count} - {pendingCollapseKeys.has(collapseKey) && ( - - )} - - {pendingCreations} - - ) - }} + renderSectionHeader={renderSectionHeader} ItemSeparatorComponent={ListSeparator} // Why (#8498): manual pull-to-refresh forces a fresh snapshot after a stale-cache reconnect. refreshControl={ @@ -275,33 +279,85 @@ export function HostWorkspaceList({ colors={[colors.textSecondary]} /> } - renderItem={({ item: { row: item, view } }) => ( - - )} + renderItem={renderItem} /> )} ) } +// Takes the header's own fields, not the section, so a poll that rebuilds sections skips unchanged headers. +const HostSectionHeader = memo(function HostSectionHeader(props: { + title: string + icon: Section['icon'] + isProjectGroup: boolean | undefined + projectGroupDepth: number | undefined + pendingCreations: Section['pendingCreations'] + collapseKey: string + collapsed: boolean + count: number + pending: boolean + groupByRepo: boolean + repoLook: RepoLook | null | undefined + onToggle: (collapseKey: string) => void +}) { + useMobileColorScheme() + const pendingCreations = props.pendingCreations?.map((creation) => ( + + )) + if (!props.title) { + return pendingCreations ? <>{pendingCreations} : null + } + return ( + <> + [ + styles.sectionHeader, + props.projectGroupDepth + ? { + paddingLeft: + WORKTREE_ROW_LAYOUT.indicatorInset + + projectGroupHeaderIndent(props.projectGroupDepth) + } + : null, + pressed && { backgroundColor: colors.translucentFill } + ]} + onPress={() => props.onToggle(props.collapseKey)} + > + + + + + + {props.icon === 'pin' && ( + + )} + {props.groupByRepo && props.isProjectGroup ? ( + + ) : props.groupByRepo ? ( + + + + ) : null} + {props.title} + {props.count} + {props.pending && ( + + )} + + {pendingCreations} + + ) +}) + function ListSeparator() { useMobileColorScheme() return