authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-14 17:45:50+00:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 17:45:50+00:00
logce6f8ce36e240d1c615e2130fc017652cbc1ec58
tree2c5658458ddbd2a06b28aab4b404b83a53cc48b6
parentcae6c6627a323d1d33a340842b0eb384807daedb
signature Signed by PGP key B5690EEEBB952194

fix(pr): keep known state in sync across controls (#18408)


2 files changed, 106 insertions(+), 4 deletions(-)

apps/web/src/state/pullRequests.test.ts+36-1
......@@ -2,7 +2,11 @@ import { SourceControlProviderKind } from "@t3tools/contracts";
22import { ProjectId, type PullRequestSummary } from "@t3tools/contracts";
33import { describe, expect, it } from "vite-plus/test";
44
5import { newestPullRequestObservation, newestPullRequestSummary } from "./pullRequests";
5import {
6 newestPullRequestObservation,
7 newestPullRequestSummary,
8 threadPullRequestLinkToSummary,
9} from "./pullRequests";
610
711function summary(overrides: Partial<PullRequestSummary> = {}): PullRequestSummary {
812 return {
......@@ -81,4 +85,35 @@ describe("pull request summary cache", () => {
8185 expect(newestPullRequestSummary(older, reopened)).toBe(reopened);
8286 expect(newestPullRequestSummary(reopened, older)).toBe(reopened);
8387 });
88
89 it("reuses saved thread state while detail is stale and lets newer detail replace it", () => {
90 const stale = summary();
91 const link = {
92 host: "github.com",
93 repository: stale.repository,
94 number: stale.number,
95 url: stale.url,
96 source: "manual" as const,
97 linkedAt: stale.updatedAt,
98 stack: null,
99 snapshot: {
100 ...stale,
101 state: "closed" as const,
102 isDraft: false,
103 updatedAt: "2026-09-11T00:00:00Z",
104 syncedAt: "2026-09-12T00:00:00Z",
105 },
106 };
107 const saved = threadPullRequestLinkToSummary(link, stale.projectId);
108 expect(newestPullRequestSummary(stale, saved)?.state).toBe("closed");
109 const fresh = summary({ isDraft: true, updatedAt: "2026-09-13T00:00:00Z" });
110 expect(newestPullRequestSummary(fresh, saved)).toBe(fresh);
111 expect(threadPullRequestLinkToSummary({ ...link, snapshot: null }, stale.projectId)).toBeNull();
112 expect(
113 threadPullRequestLinkToSummary(
114 { ...link, snapshot: { ...link.snapshot, updatedAt: null } },
115 stale.projectId,
116 ),
117 ).toBeNull();
118 });
84119});
apps/web/src/state/pullRequests.ts+70-3
......@@ -1,4 +1,5 @@
11import { useAtomValue } from "@effect/atom-react";
2import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
23import {
34 createLinkedPullRequestSummaryAtomFamily,
45 createPullRequestEnvironmentAtoms,
......@@ -6,12 +7,18 @@ import {
67} from "@t3tools/client-runtime/state/pull-requests";
78import type {
89 EnvironmentId,
10 ProjectId,
911 PullRequestListInput,
1012 PullRequestListStatsInput,
1113 PullRequestListEntry,
1214 PullRequestRef,
1315 PullRequestSummary,
16 ThreadPullRequestLink,
1417} from "@t3tools/contracts";
18import {
19 threadPullRequestKeyOf,
20 visibleThreadPullRequests,
21} from "@t3tools/shared/threadPullRequests";
1522import * as Option from "effect/Option";
1623import { AsyncResult, Atom } from "effect/reactivity";
1724import { useCallback, useLayoutEffect, useMemo } from "react";
......@@ -24,6 +31,7 @@ import {
2431 type MergedPullRequestList,
2532} from "../components/pullRequest/pullRequestList.logic";
2633import { formatEnvironmentQueryError } from "./query";
34import { environmentThreadShells } from "./threads";
2735
2836export const pullRequestEnvironment = createPullRequestEnvironmentAtoms(connectionAtomRuntime);
2937export const linkedPullRequestDetailAtom = createLinkedPullRequestSummaryAtomFamily(
......@@ -149,6 +157,58 @@ function observePullRequestSummary(
149157 });
150158}
151159
160export function threadPullRequestLinkToSummary(
161 link: ThreadPullRequestLink,
162 projectId: ProjectId,
163): PullRequestSummary | null {
164 // Without the host update time a snapshot cannot be ordered against a fresh read, so it sits out.
165 const updatedAt = link.snapshot?.updatedAt;
166 if (link.snapshot === null || updatedAt == null) return null;
167 return {
168 ...link.snapshot,
169 provider: (
170 sourceControlClients.findByChangeRequestUrl(link.url) ?? sourceControlClients.get(undefined)
171 ).kind,
172 projectId,
173 repository: link.repository,
174 number: link.number,
175 url: link.url,
176 updatedAt,
177 };
178}
179
180// Saved thread snapshots by environment and host-qualified link, indexed once per shell update
181// rather than per control. The host keeps a link to another forge from matching the project's PR.
182function savedPullRequestKey(
183 environmentId: EnvironmentId,
184 reference: Pick<PullRequestRef, "host" | "repository" | "number">,
185): string {
186 return reference.host === undefined
187 ? ""
188 : `${environmentId}:${threadPullRequestKeyOf({ ...reference, host: reference.host })}`;
189}
190
191const savedPullRequestSummariesAtom = Atom.make((get) => {
192 const summaries = new Map<string, PullRequestSummary>();
193 for (const thread of get(environmentThreadShells.threadShellsAtom)) {
194 for (const link of visibleThreadPullRequests(thread.pullRequests)) {
195 const summary = threadPullRequestLinkToSummary(link, thread.projectId);
196 if (summary === null) continue;
197 const key = savedPullRequestKey(thread.environmentId, link);
198 summaries.set(key, newestPullRequestSummary(summaries.get(key) ?? null, summary) ?? summary);
199 }
200 }
201 return summaries;
202});
203
204const savedPullRequestSummaryAtom = Atom.family((key: string) =>
205 Atom.make((get) => get(savedPullRequestSummariesAtom).get(key) ?? null).pipe(
206 // Shell updates also resend unchanged links; keep their subscribers quiet.
207 Atom.withEquality((previous, next) => JSON.stringify(previous) === JSON.stringify(next)),
208 Atom.setIdleTTL(5 * 60_000),
209 ),
210);
211
152212export function useSharedPullRequestSummary(
153213 environmentId: EnvironmentId | null,
154214 reference: PullRequestRef | null,
......@@ -161,17 +221,24 @@ export function useSharedPullRequestSummary(
161221 : pullRequestSummaryKey(environmentId, reference);
162222 const atom = observedPullRequestSummaryAtom(key);
163223 const observed = useAtomValue(atom);
224 const saved = useAtomValue(
225 savedPullRequestSummaryAtom(
226 environmentId === null || reference === null
227 ? ""
228 : savedPullRequestKey(environmentId, reference),
229 ),
230 );
164231 useLayoutEffect(() => {
165232 if (environmentId === null || reference === null || current === null || observedAt === null)
166233 return;
167234 observePullRequestSummary(environmentId, reference, current, observedAt);
168235 }, [current, environmentId, reference, observedAt]);
169 return (
236 const summary =
170237 newestPullRequestObservation(
171238 observed,
172239 current === null || observedAt === null ? null : { summary: current, observedAt },
173 )?.summary ?? current
174 );
240 )?.summary ?? current;
241 return newestPullRequestSummary(summary, saved);
175242}
176243export const pullRequestStackAtom = createPullRequestStackAtomFamily(
177244 connectionAtomRuntime,