feat(server,app): redesign git/GitHub refresh architecture with server authority

Centralizes refresh orchestration in WorkspaceGitService and GitHubService:
server coalesces, deduplicates, and throttles git/GitHub reads; clients peek
snapshots (cold reads fall through to a one-shot fetch) and never drive
refreshes. Introduces a two-TTL model (consumer vs internal), force-emit
bypass so fingerprint-matched refreshes still propagate, notifyGitMutation
wiring at ~13 action handlers, adaptive GitHub polling based on check state,
and checkout_status_update / prStatus push-to-cache wire messages. Fixes the
stale diff-stat bug and the inverted base-branch diff direction, and flips
next-action affordances promptly after commit / PR merge.
This commit is contained in:
Mohamed Boudra
2026-04-19 22:35:26 +07:00
parent 128d6668ae
commit 5a2708653c
50 changed files with 6320 additions and 1073 deletions
@@ -1,24 +0,0 @@
export type CheckoutStatusRevalidationParams = {
serverId: string;
cwd: string;
isOpen: boolean;
explorerTab: string;
};
export function checkoutStatusRevalidationKey(
params: CheckoutStatusRevalidationParams,
): string | null {
if (!params.cwd) return null;
if (!params.isOpen) return null;
if (params.explorerTab !== "changes") return null;
return `${params.serverId}:${params.cwd}`;
}
export function nextCheckoutStatusRefetchDecision(
prevKey: string | null,
nextKey: string | null,
): { nextSeenKey: string | null; shouldRefetch: boolean } {
if (!nextKey) return { nextSeenKey: null, shouldRefetch: false };
if (prevKey === nextKey) return { nextSeenKey: prevKey, shouldRefetch: false };
return { nextSeenKey: nextKey, shouldRefetch: true };
}
@@ -1,13 +1,11 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useId, useMemo } from "react";
import { useEffect, useId, useMemo } from "react";
import { useIsCompactFormFactor } from "@/constants/layout";
import { usePanelStore } from "@/stores/panel-store";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages";
import { orderCheckoutDiffFiles } from "./checkout-diff-order";
const CHECKOUT_DIFF_STALE_TIME = 30_000;
function checkoutDiffQueryKey(
serverId: string,
cwd: string,
@@ -95,7 +93,10 @@ export function useCheckoutDiffQuery({
};
},
enabled: !!client && isConnected && !!cwd && enabled,
staleTime: CHECKOUT_DIFF_STALE_TIME,
staleTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
});
useEffect(() => {
@@ -118,9 +119,6 @@ export function useCheckoutDiffQuery({
let cancelled = false;
const unsubscribeUpdate = client.on("checkout_diff_update", (message) => {
if (message.type !== "checkout_diff_update") {
return;
}
if (message.payload.subscriptionId !== subscriptionId) {
return;
}
@@ -134,9 +132,6 @@ export function useCheckoutDiffQuery({
const unsubscribeSubscribeResponse = client.on(
"subscribe_checkout_diff_response",
(message) => {
if (message.type !== "subscribe_checkout_diff_response") {
return;
}
if (message.payload.subscriptionId !== subscriptionId) {
return;
}
@@ -207,10 +202,6 @@ export function useCheckoutDiffQuery({
queryClient,
]);
const refresh = useCallback(() => {
return query.refetch();
}, [query]);
const payload = query.data ?? null;
const payloadError = payload?.error ?? null;
@@ -221,6 +212,5 @@ export function useCheckoutDiffQuery({
isFetching: query.isFetching,
isError: query.isError || Boolean(payloadError),
error: query.error,
refresh,
};
}
@@ -1,10 +1,8 @@
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { CheckoutPrStatusResponse } from "@server/shared/messages";
const CHECKOUT_PR_STATUS_STALE_TIME = 20_000;
const WORKSPACE_PR_HINT_REFETCH_INTERVAL = 60_000;
export function checkoutPrStatusQueryKey(serverId: string, cwd: string) {
return ["checkoutPrStatus", serverId, cwd] as const;
}
@@ -16,6 +14,7 @@ interface UseCheckoutPrStatusQueryOptions {
}
export type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
export interface PrHint {
url: string;
number: number;
@@ -71,9 +70,24 @@ export function useCheckoutPrStatusQuery({
cwd,
enabled = true,
}: UseCheckoutPrStatusQueryOptions) {
const queryClient = useQueryClient();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
useEffect(() => {
if (!client || !isConnected || !cwd) {
return;
}
return client.on("checkout_status_update", (message) => {
const prStatus = message.payload.prStatus;
if (!prStatus || prStatus.cwd !== cwd) {
return;
}
queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, cwd), prStatus);
});
}, [client, isConnected, cwd, queryClient, serverId]);
const query = useQuery({
queryKey: checkoutPrStatusQueryKey(serverId, cwd),
queryFn: async () => {
@@ -83,8 +97,10 @@ export function useCheckoutPrStatusQuery({
return await client.checkoutPrStatus(cwd);
},
enabled: !!client && isConnected && !!cwd && enabled,
staleTime: CHECKOUT_PR_STATUS_STALE_TIME,
refetchInterval: 15_000,
staleTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
});
return {
@@ -95,7 +111,6 @@ export function useCheckoutPrStatusQuery({
isFetching: query.isFetching,
isError: query.isError,
error: query.error,
refresh: query.refetch,
};
}
@@ -104,9 +119,24 @@ export function useWorkspacePrHint({
cwd,
enabled = true,
}: UseCheckoutPrStatusQueryOptions): PrHint | null {
const queryClient = useQueryClient();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
useEffect(() => {
if (!client || !isConnected || !cwd) {
return;
}
return client.on("checkout_status_update", (message) => {
const prStatus = message.payload.prStatus;
if (!prStatus || prStatus.cwd !== cwd) {
return;
}
queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, cwd), prStatus);
});
}, [client, isConnected, cwd, queryClient, serverId]);
const query = useQuery<CheckoutPrStatusPayload, Error, PrHint | null>({
queryKey: checkoutPrStatusQueryKey(serverId, cwd),
queryFn: async () => {
@@ -116,8 +146,10 @@ export function useWorkspacePrHint({
return await client.checkoutPrStatus(cwd);
},
enabled: !!client && isConnected && !!cwd && enabled,
staleTime: CHECKOUT_PR_STATUS_STALE_TIME,
refetchInterval: WORKSPACE_PR_HINT_REFETCH_INTERVAL,
staleTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
select: selectWorkspacePrHint,
});
@@ -1,75 +1,317 @@
import { describe, expect, it } from "vitest";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { JSDOM } from "jsdom";
import {
checkoutStatusRevalidationKey,
nextCheckoutStatusRefetchDecision,
} from "./checkout-status-revalidation";
QueryClient,
QueryClientProvider,
focusManager,
onlineManager,
} from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CheckoutStatusResponse } from "@server/shared/messages";
import { checkoutStatusQueryKey, useCheckoutStatusQuery } from "./use-checkout-status-query";
type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
const { mockRuntime, mockClient, checkoutStatusUpdateHandlers } = vi.hoisted(() => {
const checkoutStatusUpdateHandlers = new Set<(message: unknown) => void>();
const mockClient = {
getCheckoutStatus: vi.fn(),
on: vi.fn((type: string, handler: (message: unknown) => void) => {
if (type !== "checkout_status_update") {
return () => {};
}
checkoutStatusUpdateHandlers.add(handler);
return () => {
checkoutStatusUpdateHandlers.delete(handler);
};
}),
};
return {
mockClient,
checkoutStatusUpdateHandlers,
mockRuntime: {
client: mockClient,
isConnected: true,
},
};
});
vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeClient: () => mockRuntime.client,
useHostRuntimeIsConnected: () => mockRuntime.isConnected,
}));
const serverId = "server-1";
const cwd = "/repo";
function checkoutStatus(overrides: Partial<CheckoutStatusPayload> = {}): CheckoutStatusPayload {
return {
cwd,
error: null,
requestId: "checkout-status-1",
isGit: true,
isPaseoOwnedWorktree: false,
repoRoot: cwd,
currentBranch: "main",
isDirty: false,
baseRef: "origin/main",
aheadBehind: { ahead: 0, behind: 0 },
aheadOfOrigin: 0,
behindOfOrigin: 0,
hasRemote: true,
remoteUrl: "git@github.com:getpaseo/paseo.git",
...overrides,
} as CheckoutStatusPayload;
}
function createTestQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
}
function renderCheckoutStatusHook({
queryClient = createTestQueryClient(),
hookCwd = cwd,
}: {
queryClient?: QueryClient;
hookCwd?: string;
} = {}) {
let latest: ReturnType<typeof useCheckoutStatusQuery> | null = null;
function Probe() {
latest = useCheckoutStatusQuery({ serverId, cwd: hookCwd });
return null;
}
const container = document.getElementById("root");
if (!container) {
throw new Error("Missing root container");
}
const root = createRoot(container);
return {
get latest() {
if (!latest) {
throw new Error("Expected hook result");
}
return latest;
},
async mount() {
await act(async () => {
root.render(
React.createElement(
QueryClientProvider,
{ client: queryClient },
React.createElement(Probe),
),
);
});
},
async unmount() {
await act(async () => {
root.unmount();
});
},
};
}
async function waitForExpectation(assertion: () => void): Promise<void> {
let lastError: unknown;
for (let attempt = 0; attempt < 60; attempt += 1) {
try {
assertion();
return;
} catch (error) {
lastError = error;
await act(async () => {
vi.advanceTimersByTime(10);
await Promise.resolve();
});
}
}
throw lastError;
}
async function emitCheckoutStatusUpdate(payload: CheckoutStatusPayload): Promise<void> {
await act(async () => {
for (const handler of checkoutStatusUpdateHandlers) {
handler({
type: "checkout_status_update",
payload,
});
}
await Promise.resolve();
});
}
describe("useCheckoutStatusQuery", () => {
describe("checkoutStatusRevalidationKey", () => {
it("returns null when sidebar is closed", () => {
expect(
checkoutStatusRevalidationKey({
serverId: "daemon-1",
cwd: "/path/to/project",
isOpen: false,
explorerTab: "changes",
}),
).toBeNull();
beforeEach(() => {
vi.useFakeTimers();
const dom = new JSDOM("<!doctype html><html><body><div id='root'></div></body></html>", {
url: "http://localhost",
});
it("returns null when tab is not changes", () => {
expect(
checkoutStatusRevalidationKey({
serverId: "daemon-1",
cwd: "/path/to/project",
isOpen: true,
explorerTab: "files",
}),
).toBeNull();
Object.defineProperty(globalThis, "document", {
value: dom.window.document,
configurable: true,
});
Object.defineProperty(globalThis, "window", {
value: dom.window,
configurable: true,
});
Object.defineProperty(globalThis, "navigator", {
value: dom.window.navigator,
configurable: true,
});
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
value: true,
configurable: true,
});
it("returns a stable key when open and on changes tab", () => {
expect(
checkoutStatusRevalidationKey({
serverId: "daemon-1",
cwd: "/path/to/project",
isOpen: true,
explorerTab: "changes",
}),
).toBe("daemon-1:/path/to/project");
});
mockRuntime.client = mockClient;
mockRuntime.isConnected = true;
mockClient.getCheckoutStatus.mockReset();
mockClient.on.mockClear();
checkoutStatusUpdateHandlers.clear();
});
describe("nextCheckoutStatusRefetchDecision", () => {
it("refetches only once per key until reset", () => {
const key = "daemon-1:/path/to/project";
afterEach(() => {
focusManager.setFocused(undefined);
onlineManager.setOnline(true);
vi.useRealTimers();
});
expect(nextCheckoutStatusRefetchDecision(null, key)).toEqual({
nextSeenKey: key,
shouldRefetch: true,
});
it("does not schedule polling after the cold read", async () => {
mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus());
const hook = renderCheckoutStatusHook();
expect(nextCheckoutStatusRefetchDecision(key, key)).toEqual({
nextSeenKey: key,
shouldRefetch: false,
});
expect(nextCheckoutStatusRefetchDecision(key, null)).toEqual({
nextSeenKey: null,
shouldRefetch: false,
});
expect(nextCheckoutStatusRefetchDecision(null, key)).toEqual({
nextSeenKey: key,
shouldRefetch: true,
});
await hook.mount();
await waitForExpectation(() => {
expect(hook.latest.status?.currentBranch).toBe("main");
});
it("refetches again when cwd changes while active", () => {
expect(nextCheckoutStatusRefetchDecision("daemon-1:/path/a", "daemon-1:/path/b")).toEqual({
nextSeenKey: "daemon-1:/path/b",
shouldRefetch: true,
});
await act(async () => {
vi.advanceTimersByTime(60_000);
});
expect(mockClient.getCheckoutStatus).toHaveBeenCalledTimes(1);
});
it("returns the cached snapshot without calling the daemon", async () => {
const queryClient = createTestQueryClient();
const cached = checkoutStatus({ requestId: "cached" });
queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), cached);
mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus({ requestId: "uncached" }));
const hook = renderCheckoutStatusHook({ queryClient });
await hook.mount();
expect(hook.latest.status).toEqual(cached);
expect(mockClient.getCheckoutStatus).not.toHaveBeenCalled();
});
it("fetches once on a cold read and uses cache on remount", async () => {
const queryClient = createTestQueryClient();
const fetched = checkoutStatus({ requestId: "cold-read" });
mockClient.getCheckoutStatus.mockResolvedValue(fetched);
const firstHook = renderCheckoutStatusHook({ queryClient });
await firstHook.mount();
await waitForExpectation(() => {
expect(firstHook.latest.status).toEqual(fetched);
});
await firstHook.unmount();
const secondHook = renderCheckoutStatusHook({ queryClient });
await secondHook.mount();
expect(secondHook.latest.status).toEqual(fetched);
expect(mockClient.getCheckoutStatus).toHaveBeenCalledTimes(1);
});
it("does not refetch checkout status on focus or reconnect", async () => {
mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus());
const hook = renderCheckoutStatusHook();
await hook.mount();
await waitForExpectation(() => {
expect(hook.latest.status?.currentBranch).toBe("main");
});
await act(async () => {
focusManager.setFocused(false);
focusManager.setFocused(true);
onlineManager.setOnline(false);
onlineManager.setOnline(true);
await Promise.resolve();
});
expect(mockClient.getCheckoutStatus).toHaveBeenCalledTimes(1);
});
it("server-pushed checkout status update writes into the React Query cache at checkoutStatusQueryKey(serverId, cwd)", async () => {
const queryClient = createTestQueryClient();
mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus({ requestId: "initial" }));
const hook = renderCheckoutStatusHook({ queryClient });
await hook.mount();
await waitForExpectation(() => {
expect(hook.latest.status?.requestId).toBe("initial");
});
mockClient.getCheckoutStatus.mockClear();
const pushed = checkoutStatus({
requestId: "server-push",
currentBranch: "pushed-branch",
isDirty: true,
aheadBehind: { ahead: 2, behind: 1 },
aheadOfOrigin: 2,
behindOfOrigin: 1,
});
await emitCheckoutStatusUpdate(pushed);
expect(mockClient.getCheckoutStatus).not.toHaveBeenCalled();
expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toEqual(pushed);
});
it("server push only updates the matching cwd's cache key, not other workspaces", async () => {
const queryClient = createTestQueryClient();
const otherCwd = "/other-repo";
const otherCached = checkoutStatus({
cwd: otherCwd,
repoRoot: otherCwd,
requestId: "other-cached",
currentBranch: "other-main",
});
queryClient.setQueryData(checkoutStatusQueryKey(serverId, otherCwd), otherCached);
mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus({ requestId: "initial" }));
const hook = renderCheckoutStatusHook({ queryClient });
await hook.mount();
await waitForExpectation(() => {
expect(hook.latest.status?.requestId).toBe("initial");
});
const pushed = checkoutStatus({
requestId: "server-push",
currentBranch: "pushed-branch",
});
await emitCheckoutStatusUpdate(pushed);
expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toEqual(pushed);
expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, otherCwd))).toEqual(
otherCached,
);
});
});
@@ -1,13 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import { useEffect, useMemo, useRef } from "react";
import { useIsCompactFormFactor } from "@/constants/layout";
import { usePanelStore } from "@/stores/panel-store";
import { type QueryClient, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { CheckoutStatusResponse } from "@server/shared/messages";
import {
checkoutStatusRevalidationKey,
nextCheckoutStatusRefetchDecision,
} from "./checkout-status-revalidation";
export const CHECKOUT_STATUS_STALE_TIME = 15_000;
@@ -22,22 +16,56 @@ interface UseCheckoutStatusQueryOptions {
export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
interface CheckoutStatusClient {
getCheckoutStatus: (cwd: string) => Promise<CheckoutStatusPayload>;
}
function fetchCheckoutStatus(
client: { getCheckoutStatus: (cwd: string) => Promise<CheckoutStatusPayload> },
client: CheckoutStatusClient,
cwd: string,
): Promise<CheckoutStatusPayload> {
return client.getCheckoutStatus(cwd);
}
async function peekOrFetchSnapshot({
queryClient,
client,
serverId,
cwd,
}: {
queryClient: QueryClient;
client: CheckoutStatusClient;
serverId: string;
cwd: string;
}): Promise<CheckoutStatusPayload> {
const queryKey = checkoutStatusQueryKey(serverId, cwd);
const cached = queryClient.getQueryData<CheckoutStatusPayload>(queryKey);
if (cached) {
return cached;
}
const snapshot = await fetchCheckoutStatus(client, cwd);
queryClient.setQueryData(queryKey, snapshot);
return snapshot;
}
export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQueryOptions) {
const queryClient = useQueryClient();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const isMobile = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const explorerTab = usePanelStore((state) => state.explorerTab);
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
const shouldPoll = isOpen && explorerTab === "changes";
useEffect(() => {
if (!client || !isConnected || !cwd) {
return;
}
return client.on("checkout_status_update", (message) => {
if (message.payload.cwd !== cwd) {
return;
}
queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), message.payload);
});
}, [client, isConnected, cwd, queryClient, serverId]);
const query = useQuery({
queryKey: checkoutStatusQueryKey(serverId, cwd),
@@ -45,42 +73,21 @@ export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQuery
if (!client) {
throw new Error("Daemon client not available");
}
return await fetchCheckoutStatus(client, cwd);
return await peekOrFetchSnapshot({ queryClient, client, serverId, cwd });
},
enabled: !!client && isConnected && !!cwd,
staleTime: CHECKOUT_STATUS_STALE_TIME,
refetchInterval: (query) => {
if (!shouldPoll) return false;
const data = query.state.data as CheckoutStatusPayload | undefined;
return data?.isGit ? 10_000 : false;
},
refetchIntervalInBackground: shouldPoll,
refetchOnMount: "always",
staleTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
});
// Revalidate when sidebar is open with "changes" tab active.
const revalidationKey = useMemo(
() => checkoutStatusRevalidationKey({ serverId, cwd, isOpen, explorerTab }),
[serverId, cwd, isOpen, explorerTab],
);
const lastRevalidationKey = useRef<string | null>(null);
useEffect(() => {
const decision = nextCheckoutStatusRefetchDecision(
lastRevalidationKey.current,
revalidationKey,
);
lastRevalidationKey.current = decision.nextSeenKey;
if (!decision.shouldRefetch) return;
void query.refetch();
}, [revalidationKey, query.refetch]);
return {
status: query.data ?? null,
isLoading: query.isLoading,
isFetching: query.isFetching,
isError: query.isError,
error: query.error,
refresh: query.refetch,
};
}
+194 -3
View File
@@ -1,8 +1,13 @@
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { JSDOM } from "jsdom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
QueryClient,
QueryClientProvider,
focusManager,
onlineManager,
} from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type {
CheckoutPrStatusResponse,
PullRequestTimelineResponse,
@@ -18,14 +23,25 @@ type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
type PullRequestTimelinePayload = PullRequestTimelineResponse["payload"];
const { mockRuntime, mockClient } = vi.hoisted(() => {
const { mockRuntime, mockClient, checkoutStatusUpdateHandlers } = vi.hoisted(() => {
const checkoutStatusUpdateHandlers = new Set<(message: unknown) => void>();
const mockClient = {
checkoutPrStatus: vi.fn(),
pullRequestTimeline: vi.fn(),
on: vi.fn((type: string, handler: (message: unknown) => void) => {
if (type !== "checkout_status_update") {
return () => {};
}
checkoutStatusUpdateHandlers.add(handler);
return () => {
checkoutStatusUpdateHandlers.delete(handler);
};
}),
};
return {
mockClient,
checkoutStatusUpdateHandlers,
mockRuntime: {
client: mockClient,
isConnected: true,
@@ -85,6 +101,34 @@ function timelinePayload(
};
}
async function emitCheckoutStatusUpdatePrStatus(payload: CheckoutPrStatusPayload): Promise<void> {
await act(async () => {
for (const handler of checkoutStatusUpdateHandlers) {
handler({
type: "checkout_status_update",
payload: {
cwd: payload.cwd,
requestId: `subscription:${payload.cwd}`,
isGit: true,
isPaseoOwnedWorktree: false,
repoRoot: payload.cwd,
currentBranch: "main",
isDirty: false,
baseRef: "main",
aheadBehind: { ahead: 0, behind: 0 },
aheadOfOrigin: 0,
behindOfOrigin: 0,
hasRemote: true,
remoteUrl: "https://github.com/getpaseo/paseo.git",
error: null,
prStatus: payload,
},
});
}
await Promise.resolve();
});
}
function unsupportedTimelineError(): Error {
const error = new Error(
"Unknown request schema requestType=pull_request_timeline_request code=unknown_schema",
@@ -246,6 +290,14 @@ describe("usePrPaneData", () => {
mockRuntime.isConnected = true;
mockClient.checkoutPrStatus.mockReset();
mockClient.pullRequestTimeline.mockReset();
mockClient.on.mockClear();
checkoutStatusUpdateHandlers.clear();
});
afterEach(() => {
focusManager.setFocused(undefined);
onlineManager.setOnline(true);
vi.useRealTimers();
});
it("returns null when status has no PR number", async () => {
@@ -349,6 +401,82 @@ describe("usePrPaneData", () => {
expect(mockClient.checkoutPrStatus).toHaveBeenCalledTimes(1);
});
it("server-pushed PR status update writes into the checkout PR status cache", async () => {
const queryClient = createTestQueryClient();
mockClient.checkoutPrStatus.mockResolvedValue(
statusPayload({
status: status({
checksStatus: "pending",
checks: [{ name: "test", status: "pending", url: "https://github.com/checks/1" }],
}),
}),
);
mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload());
const hook = renderPrPaneHook({ queryClient });
await hook.mount();
await waitForExpectation(() => {
expect(hook.latest.data?.checks[0]?.status).toBe("pending");
});
mockClient.checkoutPrStatus.mockClear();
await emitCheckoutStatusUpdatePrStatus(
statusPayload({
requestId: "server-push",
status: status({
checksStatus: "success",
checks: [{ name: "test", status: "success", url: "https://github.com/checks/1" }],
}),
}),
);
await waitForExpectation(() => {
expect(hook.latest.data?.checks[0]?.status).toBe("success");
});
expect(queryClient.getQueryData(["checkoutPrStatus", serverId, cwd])).toEqual(
statusPayload({
requestId: "server-push",
status: status({
checksStatus: "success",
checks: [{ name: "test", status: "success", url: "https://github.com/checks/1" }],
}),
}),
);
expect(mockClient.checkoutPrStatus).not.toHaveBeenCalled();
});
it("server-pushed PR status for a different cwd does not overwrite the current cache", async () => {
const queryClient = createTestQueryClient();
const initial = statusPayload({
status: status({
checksStatus: "pending",
checks: [{ name: "test", status: "pending", url: "https://github.com/checks/1" }],
}),
});
mockClient.checkoutPrStatus.mockResolvedValue(initial);
mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload());
const hook = renderPrPaneHook({ queryClient });
await hook.mount();
await waitForExpectation(() => {
expect(hook.latest.data?.checks[0]?.status).toBe("pending");
});
await emitCheckoutStatusUpdatePrStatus(
statusPayload({
cwd: "/other-repo",
requestId: "other-server-push",
status: status({
checksStatus: "success",
checks: [{ name: "test", status: "success", url: "https://github.com/checks/1" }],
}),
}),
);
expect(queryClient.getQueryData(["checkoutPrStatus", serverId, cwd])).toEqual(initial);
expect(queryClient.getQueryData(["checkoutPrStatus", serverId, "/other-repo"])).toBeUndefined();
});
it("passes repoOwner and repoName to the timeline request when present", async () => {
mockClient.checkoutPrStatus.mockResolvedValue(
statusPayload({
@@ -517,6 +645,50 @@ describe("usePrPaneData", () => {
expect(mockClient.pullRequestTimeline).not.toHaveBeenCalled();
});
it("does not poll PR status or timeline after the initial load", async () => {
vi.useFakeTimers();
mockClient.checkoutPrStatus.mockResolvedValue(statusPayload());
mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload());
const hook = renderPrPaneHook();
await hook.mount();
await waitForHookResult(() => {
expect(hook.latest.data?.number).toBe(42);
});
await act(async () => {
vi.advanceTimersByTime(120_000);
await Promise.resolve();
});
expect(mockClient.checkoutPrStatus).toHaveBeenCalledTimes(1);
expect(mockClient.pullRequestTimeline).toHaveBeenCalledTimes(1);
});
it("does not refetch PR data on focus or reconnect", async () => {
mockClient.checkoutPrStatus.mockResolvedValue(statusPayload());
mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload());
const hook = renderPrPaneHook();
await hook.mount();
await waitForExpectation(() => {
expect(hook.latest.data?.number).toBe(42);
});
await act(async () => {
focusManager.setFocused(false);
focusManager.setFocused(true);
onlineManager.setOnline(false);
onlineManager.setOnline(true);
await Promise.resolve();
});
expect(mockClient.checkoutPrStatus).toHaveBeenCalledTimes(1);
expect(mockClient.pullRequestTimeline).toHaveBeenCalledTimes(1);
});
it("reports first-load, background refresh, and non-suppressed errors", async () => {
const statusDeferred = createDeferred<CheckoutPrStatusPayload>();
mockClient.checkoutPrStatus.mockReturnValue(statusDeferred.promise);
@@ -563,6 +735,25 @@ describe("usePrPaneData", () => {
});
});
async function waitForHookResult(assertion: () => void): Promise<void> {
let lastError: unknown;
for (let attempt = 0; attempt < 60; attempt += 1) {
try {
assertion();
return;
} catch (error) {
lastError = error;
await act(async () => {
vi.advanceTimersByTime(10);
await Promise.resolve();
});
}
}
throw lastError;
}
function createDeferred<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
+4 -5
View File
@@ -7,9 +7,6 @@ import type {
import { mapPrPaneData, type PrPaneData } from "../utils/pr-pane-data";
import { useCheckoutPrStatusQuery } from "./use-checkout-pr-status-query";
const PR_PANE_TIMELINE_STALE_TIME = 15_000;
const PR_PANE_TIMELINE_REFETCH_INTERVAL = 30_000;
type CheckoutPrStatusPayloadError = CheckoutPrStatusResponse["payload"]["error"];
type PullRequestTimeline = PullRequestTimelineResponse["payload"];
@@ -81,8 +78,10 @@ export function usePrPaneData({
}
},
enabled: shouldFetchTimeline,
staleTime: PR_PANE_TIMELINE_STALE_TIME,
refetchInterval: PR_PANE_TIMELINE_REFETCH_INTERVAL,
staleTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
placeholderData: keepPreviousData,
retry: (failureCount, error) => !isUnsupportedTimelineError(error) && failureCount < 3,
});