Skip to content

Commit 68339a0

Browse files
refactor(mobile): pull request list, filters and viewed files without synchronizing effects (#25614)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent a2398c2 commit 68339a0

12 files changed

Lines changed: 350 additions & 280 deletions

‎apps/mobile/src/features/pull-requests/PullRequestFilterMenuContext.tsx‎

Lines changed: 0 additions & 50 deletions
This file was deleted.

‎apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx‎

Lines changed: 93 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
import { useAtomValue } from "@effect/atom-react";
12
import { sortProjectsByGroupOrder } from "@t3tools/client-runtime/state/project-grouping";
3+
import type { ScopedProjectRef, SidebarProjectGroupingMode } from "@t3tools/contracts";
4+
import { Atom } from "effect/reactivity";
25
import { useMemo } from "react";
36
import { Platform, ScrollView } from "react-native";
47
import type { MenuProps } from "../../components/ui/Menu.types";
58
import { ListFilterMenu } from "../workspace/ListFilterMenu";
6-
import { usePublishPullRequestFilterMenu } from "./PullRequestFilterMenuContext";
7-
import { useProjects, useThreadShells } from "../../state/entities";
9+
import { environmentProjects } from "../../state/projects";
10+
import { environmentThreadShells } from "../../state/threads";
811
import { useMobileProjectGroupingSettings } from "../../state/project-grouping";
912
import { buildHomeProjectScopes, sortHomeProjectScopes } from "../home/homeThreadList";
1013
import { DEFAULT_PR_LIST_CONTROLS, type PullRequestListControls } from "./pullRequestList";
@@ -39,32 +42,58 @@ function choices(field: string, options: Readonly<Record<string, string>>, selec
3942
}));
4043
}
4144

45+
type ProjectOrder = readonly { readonly memberProjectRefs: readonly ScopedProjectRef[] }[];
46+
const sameProjectOrder = (previous: ProjectOrder, next: ProjectOrder) =>
47+
previous.length === next.length &&
48+
previous.every((group, index) => {
49+
const members = next[index]!.memberProjectRefs;
50+
return (
51+
group.memberProjectRefs.length === members.length &&
52+
group.memberProjectRefs.every(
53+
(ref, member) =>
54+
ref.environmentId === members[member]!.environmentId &&
55+
ref.projectId === members[member]!.projectId,
56+
)
57+
);
58+
});
59+
60+
// Thread shells change on every agent tick, but the sidebar order they decide rarely does. The
61+
// equality check keeps the list screen, which builds these filters, from re-rendering on each tick.
62+
const sidebarProjectOrderFamily = Atom.family((mode: SidebarProjectGroupingMode) =>
63+
Atom.make((get) =>
64+
sortHomeProjectScopes({
65+
scopes: buildHomeProjectScopes({
66+
projects: get(environmentProjects.projectsAtom),
67+
environmentId: null,
68+
projectGroupingMode: mode,
69+
}),
70+
threads: get(environmentThreadShells.threadShellsAtom),
71+
pendingTasks: [],
72+
projectSortOrder: "updated_at",
73+
}).map((scope) => ({ memberProjectRefs: scope.projectRefs })),
74+
).pipe(Atom.withEquality(sameProjectOrder)),
75+
);
76+
4277
function useProjectsInSidebarOrder<Project extends { id: string; environmentId: string }>(
4378
projects: readonly Project[],
4479
) {
45-
const allProjects = useProjects();
46-
const threads = useThreadShells();
4780
const { sidebarProjectGroupingMode } = useMobileProjectGroupingSettings();
48-
const scopes = useMemo(
49-
() =>
50-
sortHomeProjectScopes({
51-
scopes: buildHomeProjectScopes({
52-
projects: allProjects,
53-
environmentId: null,
54-
projectGroupingMode: sidebarProjectGroupingMode,
55-
}),
56-
threads,
57-
pendingTasks: [],
58-
projectSortOrder: "updated_at",
59-
}).map((scope) => ({ memberProjectRefs: scope.projectRefs })),
60-
[allProjects, sidebarProjectGroupingMode, threads],
61-
);
81+
const scopes = useAtomValue(sidebarProjectOrderFamily(sidebarProjectGroupingMode));
6282
return useMemo(() => sortProjectsByGroupOrder(projects, scopes), [projects, scopes]);
6383
}
6484

65-
export function PullRequestListFilters(props: {
66-
/** Android sidebar rows sit further in than full-width rows; the pills follow them. */
67-
readonly sidebar?: boolean;
85+
export interface PullRequestFilterMenu {
86+
readonly actions: MenuProps["actions"];
87+
readonly onSelect: (id: string) => void;
88+
/** Whether any filter, the environment, or a non-default sort is active. */
89+
readonly customized: boolean;
90+
}
91+
92+
/**
93+
* Builds the filter pills and, on Android, the header's full filter menu. The list screen owns
94+
* the call so both the pills and the header it frames read the same menu.
95+
*/
96+
export function usePullRequestListFilters(props: {
6897
readonly environments: readonly { id: string; label: string }[];
6998
readonly environmentId: string | null;
7099
readonly onEnvironmentChange: (id: string | null) => void;
@@ -280,7 +309,7 @@ export function PullRequestListFilters(props: {
280309
];
281310
const customized = count > 0 || value.sort !== "updated" || props.environmentId !== null;
282311
// The Android header's filter button opens every filter.
283-
const headerMenu =
312+
const headerMenu: PullRequestFilterMenu | null =
284313
Platform.OS === "android"
285314
? {
286315
actions: [
@@ -296,7 +325,35 @@ export function PullRequestListFilters(props: {
296325
customized,
297326
}
298327
: null;
299-
usePublishPullRequestFilterMenu(headerMenu);
328+
return {
329+
headerMenu,
330+
select,
331+
count,
332+
more,
333+
reset,
334+
environmentLabel:
335+
props.environments.length > 1
336+
? (props.environments.find((environment) => environment.id === props.environmentId)
337+
?.label ?? "All environments")
338+
: null,
339+
environmentActions,
340+
state: value.state,
341+
stateActions,
342+
involvement: value.involvement,
343+
involvementActions,
344+
selectedLabelCount: selectedLabels.length,
345+
labelActions,
346+
author,
347+
authorActions,
348+
};
349+
}
350+
351+
export function PullRequestListFilters(props: {
352+
/** Android sidebar rows sit further in than full-width rows; the pills follow them. */
353+
readonly sidebar?: boolean;
354+
readonly filters: ReturnType<typeof usePullRequestListFilters>;
355+
}) {
356+
const { count, more, reset, select, author, selectedLabelCount } = props.filters;
300357
return (
301358
<ScrollView
302359
horizontal
@@ -319,43 +376,40 @@ export function PullRequestListFilters(props: {
319376
onSelect={select}
320377
/>
321378
)}
322-
{props.environments.length > 1 ? (
379+
{props.filters.environmentLabel !== null ? (
323380
<ListFilterMenu
324-
label={
325-
props.environments.find((environment) => environment.id === props.environmentId)
326-
?.label ?? "All environments"
327-
}
381+
label={props.filters.environmentLabel}
328382
accessibilityLabel="Choose pull request environment"
329-
actions={environmentActions}
383+
actions={props.filters.environmentActions}
330384
onSelect={select}
331385
/>
332386
) : null}
333387
<ListFilterMenu
334-
label={STATES[value.state]}
335-
active={value.state !== "open"}
388+
label={STATES[props.filters.state]}
389+
active={props.filters.state !== "open"}
336390
accessibilityLabel="Filter by state"
337-
actions={stateActions}
391+
actions={props.filters.stateActions}
338392
onSelect={select}
339393
/>
340394
<ListFilterMenu
341-
label={INVOLVEMENT[value.involvement]}
342-
active={value.involvement !== "all"}
395+
label={INVOLVEMENT[props.filters.involvement]}
396+
active={props.filters.involvement !== "all"}
343397
accessibilityLabel="Filter by involvement"
344-
actions={involvementActions}
398+
actions={props.filters.involvementActions}
345399
onSelect={select}
346400
/>
347401
<ListFilterMenu
348-
label={selectedLabels.length ? `Labels ${selectedLabels.length}` : "Labels"}
349-
active={selectedLabels.length > 0}
402+
label={selectedLabelCount ? `Labels ${selectedLabelCount}` : "Labels"}
403+
active={selectedLabelCount > 0}
350404
accessibilityLabel="Filter by label"
351-
actions={labelActions}
405+
actions={props.filters.labelActions}
352406
onSelect={select}
353407
/>
354408
<ListFilterMenu
355409
label={author === "me" ? "By you" : (author ?? "Author")}
356410
active={Boolean(author)}
357411
accessibilityLabel="Filter by author"
358-
actions={authorActions}
412+
actions={props.filters.authorActions}
359413
onSelect={select}
360414
/>
361415
</ScrollView>

‎apps/mobile/src/features/pull-requests/PullRequestReviewersScreen.tsx‎

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { useNavigation, type StaticScreenProps } from "@react-navigation/native"
77
import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
88
import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
99
import type { PullRequestReviewerCandidate } from "@t3tools/contracts";
10-
import { useEffect, useRef, useState } from "react";
10+
import { useRef, useState } from "react";
1111
import {
1212
FlatList,
1313
Platform,
@@ -86,13 +86,14 @@ export function PullRequestReviewersScreen({ route }: StaticScreenProps<PullRequ
8686
(a, b) => Number(b.isRequested) - Number(a.isRequested) || a.login.localeCompare(b.login),
8787
);
8888
})();
89-
useEffect(() => {
90-
if (candidates.length === 0) return;
89+
// A new search shows its best matches from the top.
90+
const search = (next: string) => {
91+
setQuery(next);
9192
listRef.current?.scrollToOffset({
9293
offset: Platform.OS === "ios" ? -insets.top : 0,
9394
animated: false,
9495
});
95-
}, [candidates, insets.top]);
96+
};
9697
const update = async (
9798
candidate: Pick<PullRequestReviewerCandidate, "id" | "kind" | "isRequested">,
9899
) => {
@@ -118,16 +119,16 @@ export function PullRequestReviewersScreen({ route }: StaticScreenProps<PullRequ
118119
return;
119120
}
120121
if (!pr?.capabilities.reviewers.listCandidates) {
121-
setQuery("");
122+
search("");
122123
detail.refresh();
123124
}
124125
};
125126
const listCandidates = pr?.capabilities.reviewers.listCandidates === true;
126127
const searchOptions = {
127128
placeholder: "Search reviewers",
128129
autoCapitalize: "none" as const,
129-
onChangeText: (event: { nativeEvent: { text: string } }) => setQuery(event.nativeEvent.text),
130-
onCancelButtonPress: () => setQuery(""),
130+
onChangeText: (event: { nativeEvent: { text: string } }) => search(event.nativeEvent.text),
131+
onCancelButtonPress: () => search(""),
131132
hideNavigationBar: false,
132133
hideWhenScrolling: false,
133134
obscureBackground: false,
@@ -150,7 +151,7 @@ export function PullRequestReviewersScreen({ route }: StaticScreenProps<PullRequ
150151
? [
151152
createNativeMailSearchToolbarItem({
152153
placeholder: "Search reviewers",
153-
onSearchTextChange: setQuery,
154+
onSearchTextChange: search,
154155
showsSearchDismissButton: true,
155156
searchTextChangeId: "pr-reviewer-search",
156157
}),
@@ -182,7 +183,7 @@ export function PullRequestReviewersScreen({ route }: StaticScreenProps<PullRequ
182183
placeholder="Search reviewers"
183184
placeholderTextColorClassName="accent-foreground-muted"
184185
value={query}
185-
onChangeText={setQuery}
186+
onChangeText={search}
186187
autoCapitalize="none"
187188
className="min-h-11 rounded-xl bg-grouped-card px-3 text-sm"
188189
/>
@@ -287,7 +288,7 @@ export function PullRequestReviewersScreen({ route }: StaticScreenProps<PullRequ
287288
placeholder="Reviewer login or ID"
288289
placeholderTextColorClassName="accent-foreground-muted"
289290
value={query}
290-
onChangeText={setQuery}
291+
onChangeText={search}
291292
autoCapitalize="none"
292293
autoCorrect={false}
293294
className="min-h-12 rounded-xl bg-grouped-card px-3 text-base"

‎apps/mobile/src/features/pull-requests/PullRequestToolbar.android.tsx‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useNavigation, useNavigationState } from "@react-navigation/native";
1+
import { useNavigation } from "@react-navigation/native";
22
import { useCallback, useEffect, useRef } from "react";
33
import { BackHandler, Keyboard, View, type TextInputInstance } from "react-native";
44
import { AndroidHeaderIconButton } from "../../components/ui/AndroidScreenHeader";
@@ -8,17 +8,18 @@ import { useToolbarLayout } from "../../components/ui/useToolbarLayout";
88
import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
99
import { WorkspaceListToolbarRow } from "../workspace/WorkspaceListToolbarRow";
1010
import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext";
11-
import { usePullRequestFilterMenu } from "./PullRequestFilterMenuContext";
11+
import type { PullRequestFilterMenu } from "./PullRequestListFilters";
1212
import { openSettings } from "../settings/settings-navigation";
1313

1414
/** PR search shares the thread list's Android header row. */
15-
export function PullRequestToolbar(props: { readonly onRequestVisibility?: () => void }) {
15+
export function PullRequestToolbar(props: {
16+
readonly filters: PullRequestFilterMenu | null;
17+
readonly onRequestVisibility?: () => void;
18+
}) {
1619
const navigation = useNavigation();
17-
const filters = usePullRequestFilterMenu();
20+
const { filters } = props;
1821
const workspace = useWorkspaceTabs()!;
1922
const { dispatch, getScrollOffset, recordScrollOffset, isTabActive } = workspace;
20-
const homeFocused = useNavigationState((state) => state.routes[state.index]?.name === "Home");
21-
const listFocused = homeFocused && workspace.state.selectedTab === "pullRequests";
2223
const { onRequestVisibility } = props;
2324
const tab = workspace.state.tabs.pullRequests;
2425
const input = useRef<TextInputInstance>(null);
@@ -41,9 +42,10 @@ export function PullRequestToolbar(props: { readonly onRequestVisibility?: () =>
4142
dispatch({ type: "cancel", tab: "pullRequests" });
4243
}, [dispatch, recordScrollOffset, tab.searchEntryOffset]);
4344
useEffect(() => {
44-
if (!searching || !listFocused) return;
45+
if (!searching) return;
4546
const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
46-
// Frozen Home screens can keep effects alive after a detail route opens.
47+
// Frozen Home screens stay mounted under a detail route or another tab,
48+
// so back only applies while the PR list is in front.
4749
const state = navigation.getState();
4850
if (!state || state.routes[state.index]?.name !== "Home" || !isTabActive("pullRequests"))
4951
return false;
@@ -52,7 +54,7 @@ export function PullRequestToolbar(props: { readonly onRequestVisibility?: () =>
5254
return true;
5355
});
5456
return () => subscription.remove();
55-
}, [searching, closeSearch, listFocused, navigation, isTabActive]);
57+
}, [searching, closeSearch, navigation, isTabActive]);
5658
const { paddingTop, paddingBottom } = useToolbarLayout();
5759
return (
5860
<View className="bg-header px-2" style={{ paddingTop, paddingBottom }}>

0 commit comments

Comments
 (0)