refactor(app): reshape session store reads around narrow hook surface
Eliminate sidebar re-render cascades by fixing both sides of the subscription contract: - Session-store writes preserve identity: setWorkspaces, mergeWorkspaces, removeWorkspace, and patchWorkspaceScripts return the previous Map/entry reference when content is unchanged. - HostRuntimeController.updateSnapshot only notifies when a patched field actually changed; idle probe ticks no longer replace the snapshot. - New canonical hook surface in stores/session-store-hooks.ts (useWorkspace, useWorkspaceFields, useWorkspaceStructure, useWorkspaceKeys, useResolveWorkspaceIdByCwd, useWorkspaceStatusesForBadges, useWorkspaceExecutionAuthority, useRecommendedProjectPaths, useHasWorkspaces). These are the only place to subscribe to workspaces. - 15 greedy subscribers migrated to the hook surface. The sidebar is now driven by useWorkspaceStructure for identity/ordering; each row hydrates its own descriptor via useWorkspaceFields, so a workspace update re-renders only its row. Archive-redirect callbacks switched to event-time useSessionStore.getState(). - Deleted dead code: use-session-directory.ts, workspace-fetch-debug.ts, and the unused SidebarProjectEntry aggregates (activeCount, totalWorkspaces, aggregated statusBucket). No user-visible behavior change. Targeted tests green (79 tests across host-runtime, session-store, session-store-hooks, sidebar list, sidebar row model, sidebar shortcuts, workspace source of truth).
This commit is contained in:
@@ -6,7 +6,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { usePathname } from "expo-router";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
|
||||
import { useHosts, useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useOpenProject } from "@/hooks/use-open-project";
|
||||
import { parseServerIdFromPathname } from "@/utils/host-routes";
|
||||
@@ -29,9 +29,7 @@ export function ProjectPickerModal() {
|
||||
|
||||
const client = useHostRuntimeClient(serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||
const workspaces = useSessionStore((state) =>
|
||||
serverId ? state.sessions[serverId]?.workspaces : undefined,
|
||||
);
|
||||
const recommendedPaths = useRecommendedProjectPaths(serverId);
|
||||
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -39,13 +37,6 @@ export function ProjectPickerModal() {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const openProject = useOpenProject(serverId);
|
||||
|
||||
const recommendedPaths = useMemo(() => {
|
||||
if (!workspaces) return [];
|
||||
return Array.from(workspaces.values())
|
||||
.map((workspace) => workspace.projectRootPath)
|
||||
.filter((path) => path.length > 0);
|
||||
}, [workspaces]);
|
||||
|
||||
const directorySuggestionsQuery = useQuery({
|
||||
queryKey: ["project-picker-directory-suggestions", serverId, query],
|
||||
queryFn: async () => {
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act } from "@testing-library/react";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import type { WorkspaceScriptPayload } from "@server/shared/messages";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import React from "react";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
vi.hoisted(() => {
|
||||
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
|
||||
});
|
||||
|
||||
import {
|
||||
createSidebarWorkspaceEntry,
|
||||
type SidebarProjectEntry,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useSidebarWorkspacesList } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
type HostRuntimeController,
|
||||
type HostRuntimeSnapshot,
|
||||
} from "@/runtime/host-runtime";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const SERVER_ID = "sidebar-render-count";
|
||||
|
||||
type RenderCounts = {
|
||||
frame: number;
|
||||
headers: Record<string, number>;
|
||||
rows: Record<string, number>;
|
||||
};
|
||||
|
||||
const runningScript: WorkspaceScriptPayload = {
|
||||
scriptName: "web",
|
||||
type: "service",
|
||||
hostname: "web.paseo.localhost",
|
||||
port: 3000,
|
||||
proxyUrl: "http://web.paseo.localhost:6767",
|
||||
lifecycle: "running",
|
||||
health: "healthy",
|
||||
exitCode: null,
|
||||
};
|
||||
|
||||
function workspace(input: {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectDisplayName: string;
|
||||
name: string;
|
||||
status?: WorkspaceDescriptor["status"];
|
||||
scripts?: WorkspaceDescriptor["scripts"];
|
||||
}): WorkspaceDescriptor {
|
||||
return {
|
||||
id: input.id,
|
||||
projectId: input.projectId,
|
||||
projectDisplayName: input.projectDisplayName,
|
||||
projectRootPath: `/repo/${input.projectId}`,
|
||||
workspaceDirectory: `/repo/${input.projectId}/${input.id}`,
|
||||
projectKind: "git",
|
||||
workspaceKind: input.name === "main" ? "local_checkout" : "worktree",
|
||||
name: input.name,
|
||||
status: input.status ?? "done",
|
||||
diffStat: null,
|
||||
scripts: input.scripts ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkspaces(): WorkspaceDescriptor[] {
|
||||
return [
|
||||
workspace({
|
||||
id: "a-main",
|
||||
projectId: "project-a",
|
||||
projectDisplayName: "Project A",
|
||||
name: "main",
|
||||
scripts: [runningScript],
|
||||
}),
|
||||
workspace({
|
||||
id: "a-one",
|
||||
projectId: "project-a",
|
||||
projectDisplayName: "Project A",
|
||||
name: "one",
|
||||
}),
|
||||
workspace({
|
||||
id: "a-two",
|
||||
projectId: "project-a",
|
||||
projectDisplayName: "Project A",
|
||||
name: "two",
|
||||
}),
|
||||
workspace({
|
||||
id: "b-main",
|
||||
projectId: "project-b",
|
||||
projectDisplayName: "Project B",
|
||||
name: "main",
|
||||
}),
|
||||
workspace({
|
||||
id: "b-one",
|
||||
projectId: "project-b",
|
||||
projectDisplayName: "Project B",
|
||||
name: "one",
|
||||
}),
|
||||
workspace({
|
||||
id: "b-two",
|
||||
projectId: "project-b",
|
||||
projectDisplayName: "Project B",
|
||||
name: "two",
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function makeHost(): HostProfile {
|
||||
const now = "2026-04-19T00:00:00.000Z";
|
||||
return {
|
||||
serverId: SERVER_ID,
|
||||
label: "Render Count Host",
|
||||
lifecycle: {},
|
||||
connections: [],
|
||||
preferredConnectionId: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
function initializeSidebarState(workspaces: WorkspaceDescriptor[]): void {
|
||||
act(() => {
|
||||
getHostRuntimeStore().syncHosts([makeHost()]);
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry])));
|
||||
useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true);
|
||||
useSidebarOrderStore.setState({
|
||||
projectOrderByServerId: {
|
||||
[SERVER_ID]: ["project-a", "project-b"],
|
||||
},
|
||||
workspaceOrderByServerAndProject: {
|
||||
[`${SERVER_ID}::project-a`]: [
|
||||
`${SERVER_ID}:a-main`,
|
||||
`${SERVER_ID}:a-one`,
|
||||
`${SERVER_ID}:a-two`,
|
||||
],
|
||||
[`${SERVER_ID}::project-b`]: [
|
||||
`${SERVER_ID}:b-main`,
|
||||
`${SERVER_ID}:b-one`,
|
||||
`${SERVER_ID}:b-two`,
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function resetCounts(counts: RenderCounts): void {
|
||||
counts.frame = 0;
|
||||
counts.headers = {};
|
||||
counts.rows = {};
|
||||
}
|
||||
|
||||
function incrementRecord(record: Record<string, number>, key: string): void {
|
||||
record[key] = (record[key] ?? 0) + 1;
|
||||
}
|
||||
|
||||
function ProjectHeaderProbe({
|
||||
project,
|
||||
counts,
|
||||
}: {
|
||||
project: SidebarProjectEntry;
|
||||
counts: RenderCounts;
|
||||
}): null {
|
||||
incrementRecord(counts.headers, project.projectKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
function WorkspaceRowProbe({
|
||||
serverId,
|
||||
workspaceId,
|
||||
counts,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
counts: RenderCounts;
|
||||
}): null {
|
||||
const workspace = useWorkspaceFields(serverId, workspaceId, (entry) =>
|
||||
createSidebarWorkspaceEntry({ serverId, workspace: entry }),
|
||||
);
|
||||
if (workspace) {
|
||||
incrementRecord(counts.rows, workspace.workspaceId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function SidebarFrameProbe({ counts }: { counts: RenderCounts }): ReactElement {
|
||||
counts.frame += 1;
|
||||
const { projects } = useSidebarWorkspacesList({ serverId: SERVER_ID });
|
||||
|
||||
return (
|
||||
<>
|
||||
{projects.map((project) => (
|
||||
<div key={project.projectKey}>
|
||||
<ProjectHeaderProbe project={project} counts={counts} />
|
||||
{project.workspaces.map((workspace) => (
|
||||
<WorkspaceRowProbe
|
||||
key={workspace.workspaceKey}
|
||||
serverId={workspace.serverId}
|
||||
workspaceId={workspace.workspaceId}
|
||||
counts={counts}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getHostController(): HostRuntimeController {
|
||||
const controllers = (
|
||||
getHostRuntimeStore() as unknown as {
|
||||
controllers: Map<string, HostRuntimeController>;
|
||||
}
|
||||
).controllers;
|
||||
const controller = controllers.get(SERVER_ID);
|
||||
if (!controller) {
|
||||
throw new Error("Host runtime controller was not initialized");
|
||||
}
|
||||
return controller;
|
||||
}
|
||||
|
||||
function updateControllerSnapshot(
|
||||
patch: Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>,
|
||||
): void {
|
||||
(
|
||||
getHostController() as unknown as {
|
||||
updateSnapshot: (
|
||||
patch: Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>,
|
||||
) => void;
|
||||
}
|
||||
).updateSnapshot(patch);
|
||||
}
|
||||
|
||||
async function renderProbe(counts: RenderCounts): Promise<{ root: Root; container: HTMLElement }> {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<SidebarFrameProbe counts={counts} />);
|
||||
});
|
||||
resetCounts(counts);
|
||||
return { root, container };
|
||||
}
|
||||
|
||||
describe("sidebar workspace render isolation", () => {
|
||||
let root: Root | null = null;
|
||||
let container: HTMLElement | null = null;
|
||||
|
||||
beforeEach(async () => {
|
||||
initializeSidebarState(createWorkspaces());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
act(() => {
|
||||
getHostRuntimeStore().syncHosts([]);
|
||||
useSessionStore.getState().clearSession(SERVER_ID);
|
||||
useSidebarOrderStore.setState({
|
||||
projectOrderByServerId: {},
|
||||
workspaceOrderByServerAndProject: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("re-renders only the changed workspace row for a status update", async () => {
|
||||
const counts: RenderCounts = { frame: 0, headers: {}, rows: {} };
|
||||
({ root, container } = await renderProbe(counts));
|
||||
|
||||
act(() => {
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [
|
||||
{
|
||||
...createWorkspaces()[1]!,
|
||||
status: "running",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
expect(counts.frame).toBe(0);
|
||||
expect(counts.headers).toEqual({});
|
||||
expect(counts.rows).toEqual({ "a-one": 1 });
|
||||
});
|
||||
|
||||
it("does not re-render the sidebar for a host-runtime probe tick with no content change", async () => {
|
||||
const counts: RenderCounts = { frame: 0, headers: {}, rows: {} };
|
||||
({ root, container } = await renderProbe(counts));
|
||||
|
||||
act(() => {
|
||||
const probeByConnectionId = getHostController().getSnapshot().probeByConnectionId;
|
||||
updateControllerSnapshot({
|
||||
probeByConnectionId: new Map(probeByConnectionId),
|
||||
});
|
||||
});
|
||||
|
||||
expect(counts).toEqual({ frame: 0, headers: {}, rows: {} });
|
||||
});
|
||||
|
||||
it("does not re-render for a deep-equal scripts patch", async () => {
|
||||
const counts: RenderCounts = { frame: 0, headers: {}, rows: {} };
|
||||
({ root, container } = await renderProbe(counts));
|
||||
|
||||
act(() => {
|
||||
useSessionStore.getState().setWorkspaces(SERVER_ID, (current) =>
|
||||
patchWorkspaceScripts(current, {
|
||||
workspaceId: "a-main",
|
||||
scripts: [{ ...runningScript }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(counts).toEqual({ frame: 0, headers: {}, rows: {} });
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
} from "@/utils/host-routes";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import {
|
||||
createSidebarWorkspaceEntry,
|
||||
type SidebarProjectEntry,
|
||||
type SidebarWorkspaceEntry,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
@@ -92,7 +93,12 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query";
|
||||
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
|
||||
import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
normalizeWorkspaceDescriptor,
|
||||
useSessionStore,
|
||||
type WorkspaceDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import {
|
||||
@@ -114,7 +120,6 @@ function toProjectIconDataUri(icon: { mimeType: string; data: string } | null):
|
||||
const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey;
|
||||
|
||||
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
|
||||
const EMPTY_WORKSPACES = new Map();
|
||||
const WORKSPACE_STATUS_DOT_WIDTH = 14;
|
||||
const DEFAULT_STATUS_DOT_SIZE = 7;
|
||||
const EMPHASIZED_STATUS_DOT_SIZE = 9;
|
||||
@@ -194,6 +199,19 @@ interface WorkspaceRowInnerProps {
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
}
|
||||
|
||||
function useSidebarWorkspaceEntry(
|
||||
serverId: string | null,
|
||||
workspaceId: string | null,
|
||||
): SidebarWorkspaceEntry | null {
|
||||
const projectWorkspaceEntry = useCallback(
|
||||
(workspace: WorkspaceDescriptor): SidebarWorkspaceEntry =>
|
||||
createSidebarWorkspaceEntry({ serverId: serverId ?? "", workspace }),
|
||||
[serverId],
|
||||
);
|
||||
|
||||
return useWorkspaceFields(serverId, workspaceId, projectWorkspaceEntry);
|
||||
}
|
||||
|
||||
export function PrBadge({ hint }: { hint: PrHint }) {
|
||||
const { theme } = useUnistyles();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
@@ -1151,9 +1169,6 @@ function WorkspaceRowWithMenu({
|
||||
const toast = useToast();
|
||||
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
|
||||
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
|
||||
const sessionWorkspaces = useSessionStore(
|
||||
(state) => state.sessions[workspace.serverId]?.workspaces ?? EMPTY_WORKSPACES,
|
||||
);
|
||||
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
||||
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
@@ -1181,10 +1196,11 @@ function WorkspaceRowWithMenu({
|
||||
buildWorkspaceArchiveRedirectRoute({
|
||||
serverId: workspace.serverId,
|
||||
archivedWorkspaceId: workspace.workspaceId,
|
||||
workspaces: sessionWorkspaces.values(),
|
||||
workspaces:
|
||||
useSessionStore.getState().sessions[workspace.serverId]?.workspaces.values() ?? [],
|
||||
}),
|
||||
);
|
||||
}, [activeWorkspaceSelection, sessionWorkspaces, workspace.serverId, workspace.workspaceId]);
|
||||
}, [activeWorkspaceSelection, workspace.serverId, workspace.workspaceId]);
|
||||
|
||||
const handleArchiveWorktree = useCallback(() => {
|
||||
if (isArchiving) {
|
||||
@@ -1378,9 +1394,6 @@ function NonGitProjectRowWithMenuContent({
|
||||
const toast = useToast();
|
||||
const contextMenu = useContextMenu();
|
||||
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
|
||||
const sessionWorkspaces = useSessionStore(
|
||||
(state) => state.sessions[workspace.serverId]?.workspaces ?? EMPTY_WORKSPACES,
|
||||
);
|
||||
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
||||
const redirectAfterArchive = useCallback(() => {
|
||||
if (
|
||||
@@ -1394,10 +1407,11 @@ function NonGitProjectRowWithMenuContent({
|
||||
buildWorkspaceArchiveRedirectRoute({
|
||||
serverId: workspace.serverId,
|
||||
archivedWorkspaceId: workspace.workspaceId,
|
||||
workspaces: sessionWorkspaces.values(),
|
||||
workspaces:
|
||||
useSessionStore.getState().sessions[workspace.serverId]?.workspaces.values() ?? [],
|
||||
}) as any,
|
||||
);
|
||||
}, [activeWorkspaceSelection, sessionWorkspaces, workspace.serverId, workspace.workspaceId]);
|
||||
}, [activeWorkspaceSelection, workspace.serverId, workspace.workspaceId]);
|
||||
|
||||
const handleArchiveWorkspace = useCallback(() => {
|
||||
if (isArchivingWorkspace) {
|
||||
@@ -1542,13 +1556,19 @@ function FlattenedProjectRow({
|
||||
onRemoveProject?: () => void;
|
||||
removeProjectStatus?: "idle" | "pending";
|
||||
}) {
|
||||
const workspace = useSidebarWorkspaceEntry(serverId, rowModel.workspace.workspaceId);
|
||||
|
||||
if (!workspace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (project.projectKind === "directory") {
|
||||
return (
|
||||
<NonGitProjectRowWithMenu
|
||||
project={project}
|
||||
displayName={displayName}
|
||||
iconDataUri={iconDataUri}
|
||||
workspace={rowModel.workspace}
|
||||
workspace={workspace}
|
||||
selected={rowModel.selected}
|
||||
onPress={onPress}
|
||||
shortcutNumber={shortcutNumber}
|
||||
@@ -1565,7 +1585,7 @@ function FlattenedProjectRow({
|
||||
project={project}
|
||||
displayName={displayName}
|
||||
iconDataUri={iconDataUri}
|
||||
workspace={rowModel.workspace}
|
||||
workspace={workspace}
|
||||
selected={rowModel.selected}
|
||||
chevron={rowModel.chevron}
|
||||
onPress={onPress}
|
||||
@@ -1609,9 +1629,15 @@ function WorkspaceRow({
|
||||
canCopyBranchName: boolean;
|
||||
isCreating?: boolean;
|
||||
}) {
|
||||
const hydratedWorkspace = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
|
||||
|
||||
if (!hydratedWorkspace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceRowWithMenu
|
||||
workspace={workspace}
|
||||
workspace={hydratedWorkspace}
|
||||
selected={selected}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
|
||||
Reference in New Issue
Block a user