Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import {
EnvironmentId,
ProjectId,
SourceControlProviderKind,
type PullRequestDetailView,
} from "@t3tools/contracts";
import { expect, it, vi } from "vite-plus/test";

const queryState = vi.hoisted(() => ({ data: null as PullRequestDetailView | null }));

vi.mock(import("~/state/query"), async (original) => ({
...(await original()),
useEnvironmentQuery: ((query: unknown) => ({
data: query === null ? null : queryState.data,
dataUpdatedAt: queryState.data ? 1 : 0,
isPending: query !== null && queryState.data === null,
isSuccess: query !== null && queryState.data !== null,
refresh: () => undefined,
})) as never,
}));
vi.mock(import("~/state/entities"), async (original) => ({
...(await original()),
useServerConfigs: () => new Map(),
}));
vi.mock(import("~/state/session"), async (original) => ({
...(await original()),
useEnvironmentScope: () => true,
}));
vi.mock(import("~/state/pullRequests"), async (original) => ({
...(await original()),
useSharedPullRequestSummary: () => null,
}));

import { render } from "../../../test/render";
import { usePullRequestDetail } from "./usePullRequestDetail";

const liveDetailFixture: PullRequestDetailView = {
provider: SourceControlProviderKind.make("github"),
projectId: ProjectId.make("project"),
projectTitle: "Project",
workspaceRoot: "/workspace",
repository: "owner/repo",
number: 1,
title: "Test pull request",
body: "Original description",
url: "https://github.com/owner/repo/pull/1",
author: { login: "author", name: null, avatarUrl: null },
viewer: "author",
state: "open",
isDraft: false,
mergeability: "conflicting",
additions: 1,
deletions: 0,
changedFiles: 1,
headBranch: "feature",
baseBranch: "main",
createdAt: "2026-09-01T00:00:00Z",
updatedAt: "2026-09-01T00:00:00Z",
mergedAt: null,
closedAt: null,
reviewers: [],
labels: [],
checks: [{ name: "Unit tests", status: "success", description: null, url: null }],
comments: [],
commentCount: 0,
commentsTruncated: false,
reviewThreads: [],
commits: [],
mergeCapabilities: { merge: false, squash: false, rebase: false },
capabilities: {
diff: true,
comment: false,
search: true,
actions: [],
mergeMethods: [],
review: { inlineComment: false, reply: false, resolve: false, verdicts: [] },
reviewers: { request: false, listCandidates: false },
edit: { changeRequest: true, comment: false },
},
viewerPermissions: {
actions: [],
comment: false,
resolve: false,
verdicts: [],
requestReviewers: false,
},
};

function Detail({ number = 1 }: { number?: number }) {
const { detail } = usePullRequestDetail(
EnvironmentId.make("environment"),
{ projectId: ProjectId.make("project"), repository: "owner/repo", number },
null,
);
return <p>{detail?.title ?? "Loading pull request"}</p>;
}

function setup() {
queryState.data = null;
}

it("loads live PR details and changes references when cache storage is denied", async () => {
setup();
using _storage = vi.spyOn(window, "localStorage", "get").mockImplementation(() => {
throw new DOMException("Access to storage is denied", "SecurityError");
});
await using screen = await render(<Detail />, {
// This tests the detail-loading hook, without navigation.
router: false,
});
await expect.element(screen.getByText("Loading pull request")).toBeVisible();
queryState.data = liveDetailFixture;
await screen.rerender(<Detail />);
await expect.element(screen.getByText(liveDetailFixture.title)).toBeVisible();
queryState.data = null;
await screen.rerender(<Detail number={2} />);
await expect.element(screen.getByText("Loading pull request")).toBeVisible();
});
13 changes: 3 additions & 10 deletions apps/web/src/components/pullRequest/usePullRequestDetail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { useEnvironmentScope } from "~/state/session";
import {
newestPullRequestSummary,
pullRequestEnvironment,
pullRequestSnapshotStorage,
pullRequestListEntryToSummary,
useSharedPullRequestSummary,
} from "~/state/pullRequests";
Expand Down Expand Up @@ -84,22 +85,14 @@ export function usePullRequestDetail(
checks: liveDetail?.checks ?? [],
});
const [cachedDetail, setCachedDetail] = useState(() =>
readPullRequestDetailSnapshot(
typeof window === "undefined" ? undefined : window.localStorage,
environmentId,
reference,
),
readPullRequestDetailSnapshot(pullRequestSnapshotStorage(), environmentId, reference),
);
const [cachedDetailKey, setCachedDetailKey] = useState(scopeKey);
if (cachedDetailKey !== scopeKey) {
setCachedDetailKey(scopeKey);
setCachedDetail(
detailQuery.data ??
readPullRequestDetailSnapshot(
typeof window === "undefined" ? undefined : window.localStorage,
environmentId,
reference,
),
readPullRequestDetailSnapshot(pullRequestSnapshotStorage(), environmentId, reference),
);
} else if (detailQuery.data !== null && detailQuery.data !== cachedDetail) {
setCachedDetail(detailQuery.data);
Expand Down
8 changes: 2 additions & 6 deletions apps/web/src/hooks/useOpenPanelPullRequestUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
import { projectChangeRequestUrl } from "@t3tools/client-runtime/change-request-links";
import { selectActiveRightPanelSurface, useRightPanelStore } from "../rightPanelStore";
import { useProject } from "../state/entities";
import { pullRequestEnvironment } from "../state/pullRequests";
import { pullRequestEnvironment, pullRequestSnapshotStorage } from "../state/pullRequests";
import { useEnvironmentQuery } from "../state/query";
import { useSupportsMultiplePullRequests } from "./useSupportsMultiplePullRequests";

Expand Down Expand Up @@ -61,11 +61,7 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) {
// Keep all reference/project reads before the query hook to preserve caching of reference and its detail atom.
const cachedDetail =
reference && environmentId
? readPullRequestDetailSnapshot(
typeof window === "undefined" ? undefined : window.localStorage,
environmentId,
reference,
)
? readPullRequestDetailSnapshot(pullRequestSnapshotStorage(), environmentId, reference)
: null;
const fallbackUrl = reference
? (requestedReference?.url ??
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/state/pullRequests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ function observePullRequestSummary(
}

// Reading localStorage throws where the browser blocks storage; that must not break the page.
function snapshotStorage(): Storage | undefined {
export function pullRequestSnapshotStorage(): Storage | undefined {
if (typeof window === "undefined") return undefined;
try {
return window.localStorage;
Expand Down Expand Up @@ -366,14 +366,14 @@ export function restorePullRequestList(
export function restoredPullRequestList(
targets: ReadonlyArray<EnvironmentQueryTarget<PullRequestListInput>>,
): MergedPullRequestList | null {
return restorePullRequestList(snapshotStorage(), targets);
return restorePullRequestList(pullRequestSnapshotStorage(), targets);
}

// Answers are stamped on arrival: older servers leave no other ordering for same-dated reads.
export const pullRequestEnvironment = createPullRequestEnvironmentAtoms(connectionAtomRuntime, {
list: ({ environmentId, input }, answer, registry) =>
Effect.map(Clock.currentTimeMillis, (observedAt) => {
rememberPullRequestListAnswer(snapshotStorage(), { environmentId, input }, answer);
rememberPullRequestListAnswer(pullRequestSnapshotStorage(), { environmentId, input }, answer);
for (const entry of answer.entries) {
observePullRequestSummary(
registry,
Expand All @@ -387,7 +387,7 @@ export const pullRequestEnvironment = createPullRequestEnvironmentAtoms(connecti
Effect.map(Clock.currentTimeMillis, (at) => {
// Only fresh reads seed the cold-start snapshot; a stale one could replace a newer record.
if (input.allowStale === false) {
writePullRequestDetailSnapshot(snapshotStorage(), environmentId, input, detail);
writePullRequestDetailSnapshot(pullRequestSnapshotStorage(), environmentId, input, detail);
}
observePullRequestSummary(
registry,
Expand Down
Loading