perf(mobile): trim Home and host list re-renders (#39076)

This commit is contained in:
Kelvin Amoaba
2026-11-06 16:56:29 +00:00
committed by GitHub
parent f1d8e5daa3
commit 34bdf418ce
3 changed files with 188 additions and 121 deletions
+13 -7
View File
@@ -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<WorktreeStatus> = 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: {
</Animated.View>
</>
)
}
})
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: {
</View>
</Pressable>
)
}
}, areStructurallyEqual)
+30 -25
View File
@@ -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<void> {
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' ? (
<MobileHomeUsageTab
+145 -89
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, type ReactElement } from 'react'
import { memo, useCallback, useEffect, useMemo, type ReactElement } from 'react'
import {
ActivityIndicator,
Animated,
@@ -26,10 +26,11 @@ import {
HostWorkspaceListStates
} from '../worktree/host-workspace-list-states'
import { getWorktreeDotStatus } from '../worktree/workspace-list-ordering'
import type { Section, WorkspaceListItem } from '../worktree/workspace-list-types'
import { DEFAULT_REPO_BADGE_COLOR } from '../../../src/shared/repo-badge-color'
import { getWorktreeCardDetails } from '../../../src/shared/worktree/card-properties'
import { hostScreenStyles as styles } from './host-screen-styles'
import { buildRepoLookById } from './host-repo-looks'
import { buildRepoLookById, type RepoLook } from './host-repo-looks'
import { HostPendingCreationRow } from './host-pending-creation-row'
import { useSlowPendingKeys } from './use-slow-pending-keys'
import { projectGroupHeaderIndent } from '../worktree/project-group-indent'
@@ -93,6 +94,74 @@ export function HostWorkspaceList({
() => 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 (
<HostSectionHeader
title={section.title}
icon={section.icon}
isProjectGroup={section.isProjectGroup}
projectGroupDepth={section.projectGroupDepth}
pendingCreations={section.pendingCreations}
collapseKey={collapseKey}
collapsed={collapsedGroups.has(collapseKey)}
count={section.count ?? sectionCounts.get(section.key) ?? 0}
pending={pendingCollapseKeys.has(collapseKey)}
groupByRepo={groupBy === 'repo'}
repoLook={groupBy === 'repo' && section.repo ? repoLookById.get(section.repo.id) : null}
onToggle={toggleCollapsed}
/>
)
},
[collapsedGroups, sectionCounts, pendingCollapseKeys, groupBy, repoLookById, toggleCollapsed]
)
const renderItem = useCallback(
({ item: { row: item, view } }: { item: WorkspaceListItem }) => (
<WorktreeListRow
item={item}
view={view}
isReadOnly={isReadOnly}
// Only inline agents show times, so other rows skip the 30 s clock.
now={cardDetails.inlineAgents && item.agents?.length ? now : 0}
status={getWorktreeDotStatus(item)}
repoColor={repoLookById.get(item.repoId)?.color ?? DEFAULT_REPO_BADGE_COLOR}
repoIcon={repoLookById.get(item.repoId)?.icon ?? null}
hideRepo={groupBy === 'repo' && !view.showProjectName}
cardDetails={cardDetails}
agentActivityDisplayMode={agentActivityDisplayMode}
onPress={openWorktreeSession}
onAgentPress={openWorktreeSession}
statusLive={statusLive && !view.hostContextHealthLabel}
onLongPress={item.workspaceKind === 'folder-workspace' ? undefined : setActionTarget}
onToggleLineage={toggleWorktreeLineage}
/>
),
[
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) => (
<HostPendingCreationRow key={creation.key} creation={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 (
<>
<Pressable
style={({ pressed }) => [
styles.sectionHeader,
section.projectGroupDepth
? {
paddingLeft:
WORKTREE_ROW_LAYOUT.indicatorInset +
projectGroupHeaderIndent(section.projectGroupDepth)
}
: null,
pressed && { backgroundColor: colors.translucentFill }
]}
onPress={() => settings.toggleCollapsed(collapseKey)}
>
<View style={styles.sectionChevron}>
<DisclosureChevron expanded={!isCollapsed} collapsedDeg={0} expandedDeg={90}>
<ChevronRight size={12} color={colors.textMuted} />
</DisclosureChevron>
</View>
{section.icon === 'pin' && (
<Pin size={12} color={colors.textMuted} style={styles.sectionIcon} />
)}
{state.view.groupBy === 'repo' && section.isProjectGroup ? (
<FolderTree size={12} color={colors.textPrimary} style={styles.sectionIcon} />
) : state.view.groupBy === 'repo' ? (
<View style={styles.sectionRepoIcon}>
<MobileRepoIcon
repoIcon={repoSectionIcon}
size={14}
color={repoSectionColor ?? colors.textSecondary}
/>
</View>
) : null}
<Text style={styles.sectionTitle}>{section.title}</Text>
<Text style={styles.sectionCount}>{count}</Text>
{pendingCollapseKeys.has(collapseKey) && (
<ActivityIndicator
size="small"
color={colors.textMuted}
style={styles.sectionPendingSpinner}
/>
)}
</Pressable>
{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 } }) => (
<WorktreeListRow
item={item}
view={view}
isReadOnly={isReadOnly}
now={now}
status={getWorktreeDotStatus(item)}
repoColor={repoLookById.get(item.repoId)?.color ?? DEFAULT_REPO_BADGE_COLOR}
repoIcon={repoLookById.get(item.repoId)?.icon ?? null}
hideRepo={state.view.groupBy === 'repo' && !view.showProjectName}
cardDetails={cardDetails}
agentActivityDisplayMode={state.view.agentActivityDisplayMode}
onPress={actions.openWorktreeSession}
onAgentPress={actions.openWorktreeSession}
statusLive={connState === 'connected' && !view.hostContextHealthLabel}
onLongPress={
item.workspaceKind === 'folder-workspace' ? undefined : state.setActionTarget
}
onToggleLineage={settings.toggleWorktreeLineage}
/>
)}
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) => (
<HostPendingCreationRow key={creation.key} creation={creation} />
))
if (!props.title) {
return pendingCreations ? <>{pendingCreations}</> : null
}
return (
<>
<Pressable
style={({ pressed }) => [
styles.sectionHeader,
props.projectGroupDepth
? {
paddingLeft:
WORKTREE_ROW_LAYOUT.indicatorInset +
projectGroupHeaderIndent(props.projectGroupDepth)
}
: null,
pressed && { backgroundColor: colors.translucentFill }
]}
onPress={() => props.onToggle(props.collapseKey)}
>
<View style={styles.sectionChevron}>
<DisclosureChevron expanded={!props.collapsed} collapsedDeg={0} expandedDeg={90}>
<ChevronRight size={12} color={colors.textMuted} />
</DisclosureChevron>
</View>
{props.icon === 'pin' && (
<Pin size={12} color={colors.textMuted} style={styles.sectionIcon} />
)}
{props.groupByRepo && props.isProjectGroup ? (
<FolderTree size={12} color={colors.textPrimary} style={styles.sectionIcon} />
) : props.groupByRepo ? (
<View style={styles.sectionRepoIcon}>
<MobileRepoIcon
repoIcon={props.repoLook?.icon ?? null}
size={14}
color={props.repoLook?.color ?? colors.textSecondary}
/>
</View>
) : null}
<Text style={styles.sectionTitle}>{props.title}</Text>
<Text style={styles.sectionCount}>{props.count}</Text>
{props.pending && (
<ActivityIndicator
size="small"
color={colors.textMuted}
style={styles.sectionPendingSpinner}
/>
)}
</Pressable>
{pendingCreations}
</>
)
})
function ListSeparator() {
useMobileColorScheme()
return <View style={styles.separator} />