Files
paseo/packages/app/src/stores/session-store-hooks.test.ts
T
Mohamed Boudra bd1fb311ea 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).
2026-04-19 20:19:45 +07:00

365 lines
12 KiB
TypeScript

/**
* @vitest-environment jsdom
*/
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import type { DaemonClient } from "@server/client/daemon-client";
import {
useHasWorkspaces,
useRecommendedProjectPaths,
useResolveWorkspaceIdByCwd,
useWorkspace,
useWorkspaceExecutionAuthority,
useWorkspaceFields,
useWorkspaceKeys,
useWorkspaceStatusesForBadges,
useWorkspaceStructure,
} from "./session-store-hooks";
import { useSidebarOrderStore } from "./sidebar-order-store";
import { useSessionStore, type WorkspaceDescriptor } from "./session-store";
const SERVER_ID = "test-server";
function createWorkspace(
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
): WorkspaceDescriptor {
return {
id: input.id,
projectId: input.projectId ?? "project-1",
projectDisplayName: input.projectDisplayName ?? "Project 1",
projectRootPath: input.projectRootPath ?? "/repo",
workspaceDirectory: input.workspaceDirectory ?? "/repo",
projectKind: input.projectKind ?? "git",
workspaceKind: input.workspaceKind ?? "local_checkout",
name: input.name ?? "main",
status: input.status ?? "done",
diffStat: input.diffStat ?? null,
scripts: input.scripts ?? [],
};
}
function initializeWorkspaces(workspaces: WorkspaceDescriptor[]): void {
act(() => {
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
useSessionStore
.getState()
.setWorkspaces(SERVER_ID, new Map(workspaces.map((workspace) => [workspace.id, workspace])));
});
}
afterEach(() => {
act(() => {
useSessionStore.getState().clearSession(SERVER_ID);
useSidebarOrderStore.setState({
projectOrderByServerId: {},
workspaceOrderByServerAndProject: {},
});
});
});
describe("useWorkspace", () => {
it("resolves a descriptor when the route id matches workspace identity but not the map key", () => {
const workspace = createWorkspace({ id: "workspace-a" });
act(() => {
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
useSessionStore.getState().setWorkspaces(SERVER_ID, new Map([["map-key-a", workspace]]));
});
const { result } = renderHook(() => useWorkspace(SERVER_ID, workspace.id));
expect(result.current).toBe(workspace);
});
it("keeps the descriptor reference for unrelated workspace updates", () => {
const workspaceA = createWorkspace({ id: "workspace-a", name: "A" });
const workspaceB = createWorkspace({ id: "workspace-b", name: "B" });
initializeWorkspaces([workspaceA, workspaceB]);
const { result } = renderHook(() => useWorkspace(SERVER_ID, workspaceA.id));
const before = result.current;
act(() => {
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, status: "running" }]);
});
expect(result.current).toBe(before);
act(() => {
useSessionStore
.getState()
.mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "attention" }]);
});
expect(result.current).not.toBe(before);
});
it("keeps the descriptor reference when the observed workspace is rewritten with content-equal data", () => {
const workspace = createWorkspace({ id: "workspace-a", scripts: [] });
initializeWorkspaces([workspace]);
const { result } = renderHook(() => useWorkspace(SERVER_ID, workspace.id));
const before = result.current;
act(() => {
useSessionStore
.getState()
.setWorkspaces(SERVER_ID, new Map([[workspace.id, { ...workspace, scripts: [] }]]));
});
expect(result.current).toBe(before);
});
});
describe("useWorkspaceExecutionAuthority", () => {
it("preserves the old missing-workspace message with the requested id", () => {
initializeWorkspaces([]);
const { result } = renderHook(() => useWorkspaceExecutionAuthority(SERVER_ID, "missing-id"));
expect(result.current).toMatchObject({
ok: false,
message: "Workspace not found: missing-id",
});
});
it("keeps deep-equal authority references under unrelated workspace updates", () => {
const workspaceA = createWorkspace({ id: "workspace-a", name: "A" });
const workspaceB = createWorkspace({ id: "workspace-b", name: "B" });
initializeWorkspaces([workspaceA, workspaceB]);
const { result } = renderHook(() => useWorkspaceExecutionAuthority(SERVER_ID, workspaceA.id));
const before = result.current;
act(() => {
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, status: "running" }]);
});
expect(result.current).toBe(before);
});
});
describe("useWorkspaceFields", () => {
it("keeps deep-equal projection references until projected fields change", () => {
const workspace = createWorkspace({ id: "workspace-a", name: "A", status: "done" });
initializeWorkspaces([workspace]);
const { result } = renderHook(() =>
useWorkspaceFields(SERVER_ID, workspace.id, (current) => ({
identity: {
id: current.id,
name: current.name,
},
})),
);
const before = result.current;
act(() => {
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspace, status: "running" }]);
});
expect(result.current).toBe(before);
act(() => {
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [
{
...workspace,
name: "A renamed",
status: "running",
},
]);
});
expect(result.current).not.toBe(before);
});
});
describe("useWorkspaceStructure", () => {
it("changes for membership updates but not status-only updates", () => {
const workspaceA = createWorkspace({ id: "workspace-a", name: "A" });
const workspaceB = createWorkspace({ id: "workspace-b", name: "B" });
initializeWorkspaces([workspaceA]);
const { result } = renderHook(() => useWorkspaceStructure(SERVER_ID));
const before = result.current;
act(() => {
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [workspaceB]);
});
const afterAdd = result.current;
expect(afterAdd).not.toBe(before);
expect(afterAdd.projects[0]?.workspaceKeys).toEqual(["workspace-a", "workspace-b"]);
act(() => {
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "running" }]);
});
expect(result.current).toBe(afterAdd);
});
it("changes when a structure-relevant project identity field changes", () => {
const workspace = createWorkspace({
id: "workspace-a",
projectDisplayName: "Project 1",
});
initializeWorkspaces([workspace]);
const { result } = renderHook(() => useWorkspaceStructure(SERVER_ID));
const before = result.current;
act(() => {
useSessionStore
.getState()
.mergeWorkspaces(SERVER_ID, [{ ...workspace, projectDisplayName: "Project Renamed" }]);
});
expect(result.current).not.toBe(before);
});
it("changes when persisted sidebar project order changes", () => {
const workspaceA = createWorkspace({
id: "workspace-a",
projectId: "project-a",
projectDisplayName: "Project A",
});
const workspaceB = createWorkspace({
id: "workspace-b",
projectId: "project-b",
projectDisplayName: "Project B",
});
initializeWorkspaces([workspaceA, workspaceB]);
const { result } = renderHook(() => useWorkspaceStructure(SERVER_ID));
const before = result.current;
act(() => {
useSidebarOrderStore.getState().setProjectOrder(SERVER_ID, ["project-b", "project-a"]);
});
expect(result.current).not.toBe(before);
});
});
describe("useWorkspaceKeys", () => {
it("changes for reorder updates but not content-only updates", () => {
const workspaceA = createWorkspace({ id: "workspace-a", name: "A" });
const workspaceB = createWorkspace({ id: "workspace-b", name: "B" });
initializeWorkspaces([workspaceA, workspaceB]);
const { result } = renderHook(() => useWorkspaceKeys(SERVER_ID));
const before = result.current;
expect(before).toEqual(["workspace-a", "workspace-b"]);
act(() => {
useSessionStore.getState().setWorkspaces(
SERVER_ID,
new Map([
[workspaceB.id, workspaceB],
[workspaceA.id, workspaceA],
]),
);
});
const afterReorder = result.current;
expect(afterReorder).not.toBe(before);
expect(afterReorder).toEqual(["workspace-b", "workspace-a"]);
act(() => {
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "running" }]);
});
expect(result.current).toBe(afterReorder);
});
});
describe("useRecommendedProjectPaths", () => {
it("updates when an existing workspace project root changes", () => {
const workspace = createWorkspace({ id: "workspace-a", projectRootPath: "/repo/a" });
initializeWorkspaces([workspace]);
const { result } = renderHook(() => useRecommendedProjectPaths(SERVER_ID));
act(() => {
useSessionStore
.getState()
.mergeWorkspaces(SERVER_ID, [{ ...workspace, projectRootPath: "/repo/b" }]);
});
expect(result.current).toEqual(["/repo/b"]);
});
it("keeps the path list reference under unrelated workspace updates", () => {
const workspace = createWorkspace({ id: "workspace-a", projectRootPath: "/repo/a" });
initializeWorkspaces([workspace]);
const { result } = renderHook(() => useRecommendedProjectPaths(SERVER_ID));
const before = result.current;
act(() => {
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspace, status: "running" }]);
});
expect(result.current).toBe(before);
});
});
describe("useHasWorkspaces", () => {
it("stays stable when workspace membership changes without flipping the boolean", () => {
const workspaceA = createWorkspace({ id: "workspace-a" });
const workspaceB = createWorkspace({ id: "workspace-b" });
initializeWorkspaces([workspaceA]);
const { result } = renderHook(() => useHasWorkspaces(SERVER_ID));
const before = result.current;
act(() => {
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [workspaceB]);
});
expect(result.current).toBe(before);
});
});
describe("useResolveWorkspaceIdByCwd", () => {
it("resolves by cwd and stays stable under unrelated updates", () => {
const workspaceA = createWorkspace({
id: "workspace-a",
workspaceDirectory: "/repo/a",
});
const workspaceB = createWorkspace({
id: "workspace-b",
workspaceDirectory: "/repo/b",
});
initializeWorkspaces([workspaceA, workspaceB]);
const { result } = renderHook(() => useResolveWorkspaceIdByCwd(SERVER_ID, "/repo/a"));
const before = result.current;
expect(before).toBe("workspace-a");
act(() => {
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, status: "running" }]);
});
expect(result.current).toBe(before);
});
});
describe("useWorkspaceStatusesForBadges", () => {
it("tracks status changes without changing for no-ops or unrelated descriptor updates", () => {
const workspaceA = createWorkspace({ id: "workspace-a", status: "done" });
const workspaceB = createWorkspace({ id: "workspace-b", status: "attention" });
initializeWorkspaces([workspaceA, workspaceB]);
const { result } = renderHook(() => useWorkspaceStatusesForBadges());
const before = result.current;
expect(before).toEqual(["done", "attention"]);
act(() => {
useSessionStore
.getState()
.mergeWorkspaces(SERVER_ID, [{ ...workspaceA, scripts: [...workspaceA.scripts] }]);
});
expect(result.current).toBe(before);
act(() => {
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, name: "Renamed" }]);
});
expect(result.current).toBe(before);
act(() => {
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "failed" }]);
});
expect(result.current).not.toBe(before);
expect(result.current).toEqual(["failed", "attention"]);
});
});