From 2790feb8810fbff540d738fe726480c2942c96ac Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 16 Sep 2026 10:56:43 -0700 Subject: [PATCH] perf(mobile): skip unchanged thread row renders --- apps/mobile/src/components/ProjectFavicon.tsx | 34 +++++++++++----- .../src/features/home/HomeRouteScreen.tsx | 30 ++++++++------ apps/mobile/src/features/home/HomeScreen.tsx | 16 +------- .../threads/ThreadNavigationSidebar.tsx | 16 +------- .../features/threads/thread-list-v2-items.tsx | 40 ++++++++++++------- .../threads/thread-provider-instance.test.ts | 29 ++++++++++---- .../threads/thread-provider-instance.ts | 6 +-- 7 files changed, 94 insertions(+), 77 deletions(-) diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index 932fc6779f20..044fa5cb7f24 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -1,6 +1,6 @@ import { SymbolView } from "./AppSymbol"; import { Image } from "expo-image"; -import { useLayoutEffect, useMemo, useState } from "react"; +import { memo, useLayoutEffect, useMemo, useState } from "react"; import { View } from "react-native"; import type { EnvironmentId } from "@t3tools/contracts"; import { @@ -23,7 +23,7 @@ import { const EMPTY_FAVICON_URL = Atom.make(null); /* ─── Component ──────────────────────────────────────────────────────── */ -export function ProjectFavicon(props: { +export const ProjectFavicon = memo(function ProjectFavicon(props: { readonly environmentId: EnvironmentId; readonly open?: boolean; readonly size?: number; @@ -41,15 +41,29 @@ export function ProjectFavicon(props: { faviconPath: props.faviconPath, }), ); - const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl; + const renderableFaviconUrl = useMemo( + () => (isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl), + [faviconUrl], + ); // Inline images are self-contained; remote URLs key on their revision so signed-token // rotation reuses the disk cache while a changed icon starts from the loading state. - const cacheKey = - renderableFaviconUrl && props.workspaceRoot - ? renderableFaviconUrl.startsWith("data:") - ? getProjectFaviconResourceKey(props.environmentId, props.workspaceRoot, props.faviconPath) - : getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl) - : null; + const cacheKey = useMemo( + () => + renderableFaviconUrl && props.workspaceRoot + ? renderableFaviconUrl.startsWith("data:") + ? getProjectFaviconResourceKey( + props.environmentId, + props.workspaceRoot, + props.faviconPath, + ) + : getProjectFaviconCacheKey( + props.environmentId, + props.workspaceRoot, + renderableFaviconUrl, + ) + : null, + [renderableFaviconUrl, props.environmentId, props.workspaceRoot, props.faviconPath], + ); return ( ); -} +}); function ProjectFaviconImage(props: { readonly cacheKey: string | null; diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 96200ce9604b..5af60647c701 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -1,9 +1,11 @@ import * as Arr from "effect/Array"; import * as Order from "effect/Order"; import { useNavigation } from "@react-navigation/native"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Platform, useWindowDimensions } from "react-native"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; + import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useProjects, useNavigationThreadShells } from "../../state/entities"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; @@ -35,6 +37,20 @@ export function HomeRouteScreen() { const navigation = useNavigation(); const [searchQuery, setSearchQuery] = useState(""); const handleSelectThread = useHomeThreadSelection(); + const handleNewThreadOnBranch = useCallback( + (thread: EnvironmentThreadShell) => { + navigation.navigate("NewTaskSheet", { + screen: "NewTaskDraft", + params: { + environmentId: String(thread.environmentId), + projectId: String(thread.projectId), + branch: thread.branch, + worktreePath: thread.worktreePath, + }, + }); + }, + [navigation], + ); useEffect(() => { void checkForAppUpdateOnLaunch(); @@ -216,17 +232,7 @@ export function HomeRouteScreen() { onSelectThread={handleSelectThread} onSelectPendingTask={openPendingTask} onDeletePendingTask={confirmDeletePendingTask} - onNewThreadOnBranch={(thread) => { - navigation.navigate("NewTaskSheet", { - screen: "NewTaskDraft", - params: { - environmentId: String(thread.environmentId), - projectId: String(thread.projectId), - branch: thread.branch, - worktreePath: thread.worktreePath, - }, - }); - }} + onNewThreadOnBranch={handleNewThreadOnBranch} onNewThreadInProject={(project) => { navigation.navigate("NewTaskSheet", { screen: "NewTaskDraft", diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index dacba2372f5e..ed794ebe3923 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -1,4 +1,3 @@ -import { resolveThreadProviderInstance } from "../threads/thread-provider-instance"; import type { ThreadMoveDestination } from "../threads/threadOrder"; import { createThreadMovePlanner } from "../threads/threadOrder"; import { @@ -60,7 +59,6 @@ import { buildThreadListV2Items, getThreadListV2OrderedSection, buildThreadListV2ListItems, - resolveThreadListV2ProviderDrivers, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, type ThreadListV2ListItem, @@ -844,13 +842,6 @@ export function HomeScreen(props: HomeScreenProps) { const thread = item.item.thread; const movePlanner = item.item.pinned ? threadMovePlanners.pinned : threadMovePlanners.active; const movedId = `${thread.environmentId}:${thread.id}`; - const provider = serverConfigs - .get(thread.environmentId) - ?.providers.find( - (candidate) => - candidate.instanceId === - (thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId), - ); return ( 1 ? (props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index ac9483392d0d..3f810afc85b8 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -1,4 +1,3 @@ -import { resolveThreadProviderInstance } from "./thread-provider-instance"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { createThreadMovePlanner } from "./threadOrder"; import type { @@ -85,7 +84,6 @@ import { buildThreadListV2Items, getThreadListV2OrderedSection, buildThreadListV2ListItems, - resolveThreadListV2ProviderDrivers, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, type ThreadListV2ListItem, @@ -900,13 +898,6 @@ function ThreadNavigationSidebarPane( : threadMovePlanners.active; const movedId = `${thread.environmentId}:${thread.id}`; const scopeKey = scopedProjectKey(thread.environmentId, thread.projectId); - const provider = serverConfigs - .get(thread.environmentId) - ?.providers.find( - (candidate) => - candidate.instanceId === - (thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId), - ); return ( 1 ? (savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index b75c4259f941..affb7fc8fd93 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -1,4 +1,4 @@ -import type { ThreadRowProviderInstance } from "./thread-provider-instance"; +import { resolveThreadProviderInstance } from "./thread-provider-instance"; import { RowPressable } from "../../components/RowPressable"; import { CustomSnoozeSheet } from "./CustomSnoozeSheet"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; @@ -10,7 +10,7 @@ import type { EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; -import type { EnvironmentMachineKind } from "@t3tools/contracts"; +import type { EnvironmentMachineKind, ServerConfig } from "@t3tools/contracts"; import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort"; import type { MenuAction } from "@react-native-menu/menu"; @@ -36,6 +36,7 @@ import { resolveThreadListV2SnoozeMenuSelection, threadHasUnseenCompletion, resolveThreadListV2Status, + resolveThreadListV2ProviderDrivers, resolveThreadListV2SwipeActions, type ThreadListV2Status, } from "./threadListV2"; @@ -358,12 +359,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly snoozePresetMinute: string; readonly project: EnvironmentProject | null; readonly projectTitle?: string; - /** Provider drivers back to front: earlier owners first, current last. - Empty when the environment's config has not resolved yet. */ - readonly providerDrivers: ReadonlyArray; - /** Account-aware presentation for the current provider owner. */ - readonly providerInstance: ThreadRowProviderInstance | null; - readonly providerIconUrl?: string | null; + /** Keep the environment's provider array stable across unrelated list updates. */ + readonly providers: ServerConfig["providers"] | undefined; /** Which machine hosts the thread. Null when only one environment is connected — repeating the same label on every row is noise. Mirrors the web sidebar's remote-environment cloud icon, but as text since @@ -445,6 +442,19 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const snoozedRow = props.snoozed === true; const pinnedRow = props.pinned === true; + const { providerDrivers, providerInstance, providerIconUrl } = useMemo(() => { + const provider = props.providers?.find( + (candidate) => + candidate.instanceId === + (thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId), + ); + return { + providerDrivers: resolveThreadListV2ProviderDrivers(thread, props.providers), + providerInstance: resolveThreadProviderInstance(props.providers, thread), + providerIconUrl: provider?.iconUrl, + }; + }, [thread, props.providers]); + const pr = useThreadPr(thread); const { materialYouStyleLayoutActive } = useAppearancePreferences(); @@ -930,23 +940,23 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ) : null} - {props.providerInstance ? ( + {providerInstance ? ( // Earlier owners peek out behind the current provider so a // handed-off thread shows where it has been. The current owner // keeps its account badge so same-driver instances stay distinct. - {props.providerDrivers.slice(0, -1).map((driver, index) => ( + {providerDrivers.slice(0, -1).map((driver, index) => ( ))} diff --git a/apps/mobile/src/features/threads/thread-provider-instance.test.ts b/apps/mobile/src/features/threads/thread-provider-instance.test.ts index 90805a1b9238..3e1f7930aef1 100644 --- a/apps/mobile/src/features/threads/thread-provider-instance.test.ts +++ b/apps/mobile/src/features/threads/thread-provider-instance.test.ts @@ -61,8 +61,14 @@ describe("resolveThreadProviderInstance", () => { const threadA = makeThread(environmentA, "codex"); const threadB = makeThread(environmentB, "codex"); - expect(resolveThreadProviderInstance(serverConfigs, threadA)?.accentColor).toBe("#ff8800"); - expect(resolveThreadProviderInstance(serverConfigs, threadB)?.accentColor).toBeUndefined(); + expect( + resolveThreadProviderInstance(serverConfigs.get(environmentA)?.providers, threadA) + ?.accentColor, + ).toBe("#ff8800"); + expect( + resolveThreadProviderInstance(serverConfigs.get(environmentB)?.providers, threadB) + ?.accentColor, + ).toBeUndefined(); }); it("labels a custom instance by its id so its initials differ from the default", () => { @@ -78,11 +84,16 @@ describe("resolveThreadProviderInstance", () => { ]); expect( - resolveThreadProviderInstance(serverConfigs, makeThread(environmentId, "codex"))?.displayName, + resolveThreadProviderInstance( + serverConfigs.get(environmentId)?.providers, + makeThread(environmentId, "codex"), + )?.displayName, ).toBe("Codex"); expect( - resolveThreadProviderInstance(serverConfigs, makeThread(environmentId, "codex_personal")) - ?.displayName, + resolveThreadProviderInstance( + serverConfigs.get(environmentId)?.providers, + makeThread(environmentId, "codex_personal"), + )?.displayName, ).toBe("Codex Personal"); }); @@ -110,7 +121,9 @@ describe("resolveThreadProviderInstance", () => { }, }; - expect(resolveThreadProviderInstance(serverConfigs, thread)).toMatchObject({ + expect( + resolveThreadProviderInstance(serverConfigs.get(environmentId)?.providers, thread), + ).toMatchObject({ driverKind: "codex", displayName: "Codex Work", showBadge: true, @@ -124,6 +137,8 @@ describe("resolveThreadProviderInstance", () => { ]); const thread = makeThread(environmentId, "codex"); - expect(resolveThreadProviderInstance(serverConfigs, thread)?.showBadge).toBe(false); + expect( + resolveThreadProviderInstance(serverConfigs.get(environmentId)?.providers, thread)?.showBadge, + ).toBe(false); }); }); diff --git a/apps/mobile/src/features/threads/thread-provider-instance.ts b/apps/mobile/src/features/threads/thread-provider-instance.ts index 61fb53790460..2e5b1e11a9e2 100644 --- a/apps/mobile/src/features/threads/thread-provider-instance.ts +++ b/apps/mobile/src/features/threads/thread-provider-instance.ts @@ -4,7 +4,7 @@ import { resolveProviderInstanceDisplayName, shouldShowInstanceBadge, } from "@t3tools/client-runtime/state/provider-instance-display"; -import type { EnvironmentId, ProviderDriverKind, ServerConfig } from "@t3tools/contracts"; +import type { ProviderDriverKind, ServerConfig } from "@t3tools/contracts"; /** What a thread row needs to draw the provider glyph and its account badge. */ export interface ThreadRowProviderInstance { @@ -20,10 +20,10 @@ export interface ThreadRowProviderInstance { * names a different account on every server. */ export function resolveThreadProviderInstance( - serverConfigs: ReadonlyMap, + providers: ServerConfig["providers"] | undefined, thread: EnvironmentThreadShell, ): ThreadRowProviderInstance | null { - const providers = serverConfigs.get(thread.environmentId)?.providers ?? []; + if (providers === undefined) return null; const instanceId = thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId; const snapshot = providers.find((provider) => provider.instanceId === instanceId); if (!snapshot) return null;