Skip to content

Commit 3502865

Browse files
claude[bot]github-actions[bot]claude
authored
perf: optimize mobile list rendering — memoize FlatList rows (#4636)
Five FlatList screens built their rows inline in the screen body. Every parent render — a poll, a keystroke in the search box, opening a modal — rebuilt `renderItem`, and FlatList re-rendered every visible cell. Each screen now has a `React.memo`'d row component and a `useCallback`'d `renderItem`, matching the existing DocumentRow/AssetCard/WorkflowRow pattern. `keyExtractor` moved to module scope. TriggersScreen also needed a row-identity fix: `useQueries` returns a fresh results array on every render, so the `useMemo` deriving rows never hit and every row object was rebuilt — which would have defeated the memo. Cache the conversion on the query payload instead. Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent e95afaa commit 3502865

5 files changed

Lines changed: 551 additions & 366 deletions

File tree

mobile/src/screens/CollectionsScreen.tsx

Lines changed: 60 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { RootStackParamList } from '../navigation/types';
2626
import { type CollectionResponse } from '../services/api';
2727
import { trpc } from '../trpc/client';
2828
import { useTheme } from '../hooks/useTheme';
29+
import type { ThemeColors, ThemeShadows } from '../utils/theme';
2930

3031
type Props = {
3132
navigation: NativeStackNavigationProp<RootStackParamList, 'Collections'>;
@@ -39,6 +40,54 @@ interface CreateState {
3940
saving: boolean;
4041
}
4142

43+
const keyExtractor = (collection: CollectionResponse) => collection.name;
44+
45+
const CollectionRow = React.memo(function CollectionRow({
46+
collection,
47+
colors,
48+
shadows,
49+
onDelete,
50+
}: {
51+
collection: CollectionResponse;
52+
colors: ThemeColors;
53+
shadows: ThemeShadows;
54+
onDelete: (collection: CollectionResponse) => void;
55+
}) {
56+
const handleDelete = useCallback(() => onDelete(collection), [onDelete, collection]);
57+
58+
return (
59+
<View
60+
style={[
61+
styles.card,
62+
shadows.small,
63+
{ backgroundColor: colors.cardBg, borderColor: colors.borderLight },
64+
]}
65+
>
66+
<View style={[styles.iconWrap, { backgroundColor: colors.accentMuted }]}>
67+
<Ionicons name="library-outline" size={20} color={colors.accent} />
68+
</View>
69+
<View style={styles.meta}>
70+
<Text style={[styles.name, { color: colors.text }]} numberOfLines={1}>
71+
{collection.name}
72+
</Text>
73+
<Text style={[styles.subtitle, { color: colors.textSecondary }]} numberOfLines={1}>
74+
{collection.count.toLocaleString()} {collection.count === 1 ? 'item' : 'items'}
75+
{collection.workflow_name ? ` · ${collection.workflow_name}` : ''}
76+
</Text>
77+
</View>
78+
<TouchableOpacity
79+
onPress={handleDelete}
80+
style={styles.deleteBtn}
81+
accessibilityRole="button"
82+
accessibilityLabel={`Delete ${collection.name}`}
83+
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
84+
>
85+
<Ionicons name="trash-outline" size={18} color={colors.error} />
86+
</TouchableOpacity>
87+
</View>
88+
);
89+
});
90+
4291
export default function CollectionsScreen({ navigation: _navigation }: Props) {
4392
const { colors, shadows } = useTheme();
4493
const insets = useSafeAreaInsets();
@@ -119,36 +168,16 @@ export default function CollectionsScreen({ navigation: _navigation }: Props) {
119168
);
120169
}, [deleteCollection]);
121170

122-
const renderItem = ({ item }: { item: CollectionResponse }) => (
123-
<View
124-
style={[
125-
styles.card,
126-
shadows.small,
127-
{ backgroundColor: colors.cardBg, borderColor: colors.borderLight },
128-
]}
129-
>
130-
<View style={[styles.iconWrap, { backgroundColor: colors.accentMuted }]}>
131-
<Ionicons name="library-outline" size={20} color={colors.accent} />
132-
</View>
133-
<View style={styles.meta}>
134-
<Text style={[styles.name, { color: colors.text }]} numberOfLines={1}>
135-
{item.name}
136-
</Text>
137-
<Text style={[styles.subtitle, { color: colors.textSecondary }]} numberOfLines={1}>
138-
{item.count.toLocaleString()} {item.count === 1 ? 'item' : 'items'}
139-
{item.workflow_name ? ` · ${item.workflow_name}` : ''}
140-
</Text>
141-
</View>
142-
<TouchableOpacity
143-
onPress={() => handleDelete(item)}
144-
style={styles.deleteBtn}
145-
accessibilityRole="button"
146-
accessibilityLabel={`Delete ${item.name}`}
147-
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
148-
>
149-
<Ionicons name="trash-outline" size={18} color={colors.error} />
150-
</TouchableOpacity>
151-
</View>
171+
const renderItem = useCallback(
172+
({ item }: { item: CollectionResponse }) => (
173+
<CollectionRow
174+
collection={item}
175+
colors={colors}
176+
shadows={shadows}
177+
onDelete={handleDelete}
178+
/>
179+
),
180+
[colors, shadows, handleDelete],
152181
);
153182

154183
if (isLoading) {
@@ -191,7 +220,7 @@ export default function CollectionsScreen({ navigation: _navigation }: Props) {
191220

192221
<FlatList
193222
data={filtered}
194-
keyExtractor={(c) => c.name}
223+
keyExtractor={keyExtractor}
195224
renderItem={renderItem}
196225
contentContainerStyle={[styles.list, { paddingBottom: insets.bottom + 96 }]}
197226
refreshControl={

mobile/src/screens/JobsScreen.tsx

Lines changed: 126 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { RootStackParamList } from '../navigation/types';
2222
import { type JobResponse } from '../services/api';
2323
import { trpc } from '../trpc/client';
2424
import { useTheme } from '../hooks/useTheme';
25-
import type { ThemeColors } from '../utils/theme';
25+
import type { ThemeColors, ThemeShadows } from '../utils/theme';
2626

2727
type Props = {
2828
navigation: NativeStackNavigationProp<RootStackParamList, 'Jobs'>;
@@ -86,6 +86,109 @@ export function formatRelative(iso: string | null | undefined): string {
8686
return new Date(iso).toLocaleDateString();
8787
}
8888

89+
const keyExtractor = (job: JobResponse) => job.id;
90+
91+
const JobCard = React.memo(function JobCard({
92+
job,
93+
workflowName,
94+
colors,
95+
shadows,
96+
onOpen,
97+
onCancel,
98+
}: {
99+
job: JobResponse;
100+
workflowName: string;
101+
colors: ThemeColors;
102+
shadows: ThemeShadows;
103+
onOpen: (jobId: string) => void;
104+
onCancel: (job: JobResponse) => void;
105+
}) {
106+
const handleOpen = useCallback(() => onOpen(job.id), [onOpen, job.id]);
107+
const handleCancel = useCallback(() => onCancel(job), [onCancel, job]);
108+
109+
const variant = statusVariant(job.status);
110+
const variantColor = statusColorFor(colors, variant);
111+
const duration = formatDuration(job.started_at, job.finished_at);
112+
const isRunning = variant === 'running' || variant === 'queued';
113+
114+
return (
115+
<TouchableOpacity
116+
onPress={handleOpen}
117+
activeOpacity={0.8}
118+
accessibilityRole="button"
119+
accessibilityLabel={`Open job ${workflowName}`}
120+
style={[
121+
styles.card,
122+
shadows.small,
123+
{ backgroundColor: colors.cardBg, borderColor: colors.borderLight },
124+
]}
125+
>
126+
<View style={styles.cardHeader}>
127+
<View style={[styles.statusPill, { backgroundColor: variantColor + '20' }]}>
128+
<View style={[styles.statusDot, { backgroundColor: variantColor }]} />
129+
<Text style={[styles.statusText, { color: variantColor }]}>
130+
{job.status}
131+
</Text>
132+
</View>
133+
<Text style={[styles.timeText, { color: colors.textTertiary }]}>
134+
{formatRelative(job.started_at)}
135+
</Text>
136+
</View>
137+
138+
<Text style={[styles.title, { color: colors.text }]} numberOfLines={1}>
139+
{workflowName}
140+
</Text>
141+
<Text style={[styles.idText, { color: colors.textTertiary }]} numberOfLines={1}>
142+
Job {job.id}
143+
</Text>
144+
145+
<View style={styles.metaRow}>
146+
{duration ? (
147+
<View style={styles.metaItem}>
148+
<Ionicons name="time-outline" size={13} color={colors.textSecondary} />
149+
<Text style={[styles.metaText, { color: colors.textSecondary }]}>{duration}</Text>
150+
</View>
151+
) : null}
152+
{job.job_type ? (
153+
<View style={styles.metaItem}>
154+
<Ionicons name="layers-outline" size={13} color={colors.textSecondary} />
155+
<Text style={[styles.metaText, { color: colors.textSecondary }]}>{job.job_type}</Text>
156+
</View>
157+
) : null}
158+
{typeof job.cost === 'number' ? (
159+
<View style={styles.metaItem}>
160+
<Ionicons name="card-outline" size={13} color={colors.textSecondary} />
161+
<Text style={[styles.metaText, { color: colors.textSecondary }]}>
162+
${job.cost.toFixed(4)}
163+
</Text>
164+
</View>
165+
) : null}
166+
</View>
167+
168+
{job.error ? (
169+
<View style={[styles.errorBox, { backgroundColor: colors.error + '14' }]}>
170+
<Ionicons name="alert-circle-outline" size={13} color={colors.error} />
171+
<Text style={[styles.errorText, { color: colors.error }]} numberOfLines={3}>
172+
{job.error}
173+
</Text>
174+
</View>
175+
) : null}
176+
177+
{isRunning ? (
178+
<TouchableOpacity
179+
onPress={handleCancel}
180+
style={[styles.cancelBtn, { borderColor: colors.error }]}
181+
accessibilityRole="button"
182+
accessibilityLabel="Cancel job"
183+
>
184+
<Ionicons name="stop-circle-outline" size={15} color={colors.error} />
185+
<Text style={[styles.cancelText, { color: colors.error }]}>Cancel job</Text>
186+
</TouchableOpacity>
187+
) : null}
188+
</TouchableOpacity>
189+
);
190+
});
191+
89192
export default function JobsScreen({ navigation, route }: Props) {
90193
const { colors, shadows } = useTheme();
91194
const insets = useSafeAreaInsets();
@@ -130,11 +233,6 @@ export default function JobsScreen({ navigation, route }: Props) {
130233
);
131234
}, [cancelJob]);
132235

133-
const statusColor = useCallback(
134-
(variant: StatusVariant) => statusColorFor(colors, variant),
135-
[colors],
136-
);
137-
138236
const sortedJobs = useMemo(() => {
139237
return [...jobs].sort((a, b) => {
140238
const aT = new Date(a.started_at).getTime();
@@ -143,90 +241,28 @@ export default function JobsScreen({ navigation, route }: Props) {
143241
});
144242
}, [jobs]);
145243

146-
const renderItem = ({ item }: { item: JobResponse }) => {
147-
const variant = statusVariant(item.status);
148-
const variantColor = statusColor(variant);
149-
const duration = formatDuration(item.started_at, item.finished_at);
150-
const isRunning = variant === 'running' || variant === 'queued';
151-
const workflowName = workflowNames[item.workflow_id] || `Workflow ${item.workflow_id.substring(0, 8)}`;
152-
153-
return (
154-
<TouchableOpacity
155-
onPress={() => navigation.navigate('JobDetail', { jobId: item.id })}
156-
activeOpacity={0.8}
157-
accessibilityRole="button"
158-
accessibilityLabel={`Open job ${workflowName}`}
159-
style={[
160-
styles.card,
161-
shadows.small,
162-
{ backgroundColor: colors.cardBg, borderColor: colors.borderLight },
163-
]}
164-
>
165-
<View style={styles.cardHeader}>
166-
<View style={[styles.statusPill, { backgroundColor: variantColor + '20' }]}>
167-
<View style={[styles.statusDot, { backgroundColor: variantColor }]} />
168-
<Text style={[styles.statusText, { color: variantColor }]}>
169-
{item.status}
170-
</Text>
171-
</View>
172-
<Text style={[styles.timeText, { color: colors.textTertiary }]}>
173-
{formatRelative(item.started_at)}
174-
</Text>
175-
</View>
176-
177-
<Text style={[styles.title, { color: colors.text }]} numberOfLines={1}>
178-
{workflowName}
179-
</Text>
180-
<Text style={[styles.idText, { color: colors.textTertiary }]} numberOfLines={1}>
181-
Job {item.id}
182-
</Text>
183-
184-
<View style={styles.metaRow}>
185-
{duration ? (
186-
<View style={styles.metaItem}>
187-
<Ionicons name="time-outline" size={13} color={colors.textSecondary} />
188-
<Text style={[styles.metaText, { color: colors.textSecondary }]}>{duration}</Text>
189-
</View>
190-
) : null}
191-
{item.job_type ? (
192-
<View style={styles.metaItem}>
193-
<Ionicons name="layers-outline" size={13} color={colors.textSecondary} />
194-
<Text style={[styles.metaText, { color: colors.textSecondary }]}>{item.job_type}</Text>
195-
</View>
196-
) : null}
197-
{typeof item.cost === 'number' ? (
198-
<View style={styles.metaItem}>
199-
<Ionicons name="card-outline" size={13} color={colors.textSecondary} />
200-
<Text style={[styles.metaText, { color: colors.textSecondary }]}>
201-
${item.cost.toFixed(4)}
202-
</Text>
203-
</View>
204-
) : null}
205-
</View>
206-
207-
{item.error ? (
208-
<View style={[styles.errorBox, { backgroundColor: colors.error + '14' }]}>
209-
<Ionicons name="alert-circle-outline" size={13} color={colors.error} />
210-
<Text style={[styles.errorText, { color: colors.error }]} numberOfLines={3}>
211-
{item.error}
212-
</Text>
213-
</View>
214-
) : null}
244+
const handleOpen = useCallback(
245+
(jobId: string) => {
246+
navigation.navigate('JobDetail', { jobId });
247+
},
248+
[navigation],
249+
);
215250

216-
{isRunning ? (
217-
<TouchableOpacity
218-
onPress={() => handleCancel(item)}
219-
style={[styles.cancelBtn, { borderColor: colors.error }]}
220-
accessibilityRole="button"
221-
accessibilityLabel="Cancel job"
222-
>
223-
<Ionicons name="stop-circle-outline" size={15} color={colors.error} />
224-
<Text style={[styles.cancelText, { color: colors.error }]}>Cancel job</Text>
225-
</TouchableOpacity>
226-
) : null}
227-
</TouchableOpacity>
228-
);
229-
};
251+
const renderItem = useCallback(
252+
({ item }: { item: JobResponse }) => (
253+
<JobCard
254+
job={item}
255+
workflowName={
256+
workflowNames[item.workflow_id] || `Workflow ${item.workflow_id.substring(0, 8)}`
257+
}
258+
colors={colors}
259+
shadows={shadows}
260+
onOpen={handleOpen}
261+
onCancel={handleCancel}
262+
/>
263+
),
264+
[workflowNames, colors, shadows, handleOpen, handleCancel],
265+
);
230266

231267
if (isLoading) {
232268
return (
@@ -247,7 +283,7 @@ export default function JobsScreen({ navigation, route }: Props) {
247283

248284
<FlatList
249285
data={sortedJobs}
250-
keyExtractor={(j) => j.id}
286+
keyExtractor={keyExtractor}
251287
renderItem={renderItem}
252288
contentContainerStyle={[styles.list, { paddingBottom: insets.bottom + 24 }]}
253289
refreshControl={

0 commit comments

Comments
 (0)