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";...@@ -2,7 +2,11 @@ import { SourceControlProviderKind } from "@t3tools/contracts";
2import { ProjectId, type PullRequestSummary } from "@t3tools/contracts";2import { ProjectId, type PullRequestSummary } from "@t3tools/contracts";
3import { describe, expect, it } from "vite-plus/test";3import { describe, expect, it } from "vite-plus/test";
44
5import { newestPullRequestObservation, newestPullRequestSummary } from "./pullRequests";5import {
6 newestPullRequestObservation,
7 newestPullRequestSummary,
8 threadPullRequestLinkToSummary,
9} from "./pullRequests";
610
7function summary(overrides: Partial<PullRequestSummary> = {}): PullRequestSummary {11function summary(overrides: Partial<PullRequestSummary> = {}): PullRequestSummary {
8 return {12 return {
...@@ -81,4 +85,35 @@ describe("pull request summary cache", () => {...@@ -81,4 +85,35 @@ describe("pull request summary cache", () => {
81 expect(newestPullRequestSummary(older, reopened)).toBe(reopened);85 expect(newestPullRequestSummary(older, reopened)).toBe(reopened);
82 expect(newestPullRequestSummary(reopened, older)).toBe(reopened);86 expect(newestPullRequestSummary(reopened, older)).toBe(reopened);
83 });87 });
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 });
84});119});
apps/web/src/state/pullRequests.ts+70-3
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
2import {3import {
3 createLinkedPullRequestSummaryAtomFamily,4 createLinkedPullRequestSummaryAtomFamily,
4 createPullRequestEnvironmentAtoms,5 createPullRequestEnvironmentAtoms,
...@@ -6,12 +7,18 @@ import {...@@ -6,12 +7,18 @@ import {
6} from "@t3tools/client-runtime/state/pull-requests";7} from "@t3tools/client-runtime/state/pull-requests";
7import type {8import type {
8 EnvironmentId,9 EnvironmentId,
10 ProjectId,
9 PullRequestListInput,11 PullRequestListInput,
10 PullRequestListStatsInput,12 PullRequestListStatsInput,
11 PullRequestListEntry,13 PullRequestListEntry,
12 PullRequestRef,14 PullRequestRef,
13 PullRequestSummary,15 PullRequestSummary,
16 ThreadPullRequestLink,
14} from "@t3tools/contracts";17} from "@t3tools/contracts";
18import {
19 threadPullRequestKeyOf,
20 visibleThreadPullRequests,
21} from "@t3tools/shared/threadPullRequests";
15import * as Option from "effect/Option";22import * as Option from "effect/Option";
16import { AsyncResult, Atom } from "effect/reactivity";23import { AsyncResult, Atom } from "effect/reactivity";
17import { useCallback, useLayoutEffect, useMemo } from "react";24import { useCallback, useLayoutEffect, useMemo } from "react";
...@@ -24,6 +31,7 @@ import {...@@ -24,6 +31,7 @@ import {
24 type MergedPullRequestList,31 type MergedPullRequestList,
25} from "../components/pullRequest/pullRequestList.logic";32} from "../components/pullRequest/pullRequestList.logic";
26import { formatEnvironmentQueryError } from "./query";33import { formatEnvironmentQueryError } from "./query";
34import { environmentThreadShells } from "./threads";
2735
28export const pullRequestEnvironment = createPullRequestEnvironmentAtoms(connectionAtomRuntime);36export const pullRequestEnvironment = createPullRequestEnvironmentAtoms(connectionAtomRuntime);
29export const linkedPullRequestDetailAtom = createLinkedPullRequestSummaryAtomFamily(37export const linkedPullRequestDetailAtom = createLinkedPullRequestSummaryAtomFamily(
...@@ -149,6 +157,58 @@ function observePullRequestSummary(...@@ -149,6 +157,58 @@ function observePullRequestSummary(
149 });157 });
150}158}
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
152export function useSharedPullRequestSummary(212export function useSharedPullRequestSummary(
153 environmentId: EnvironmentId | null,213 environmentId: EnvironmentId | null,
154 reference: PullRequestRef | null,214 reference: PullRequestRef | null,
...@@ -161,17 +221,24 @@ export function useSharedPullRequestSummary(...@@ -161,17 +221,24 @@ export function useSharedPullRequestSummary(
161 : pullRequestSummaryKey(environmentId, reference);221 : pullRequestSummaryKey(environmentId, reference);
162 const atom = observedPullRequestSummaryAtom(key);222 const atom = observedPullRequestSummaryAtom(key);
163 const observed = useAtomValue(atom);223 const observed = useAtomValue(atom);
224 const saved = useAtomValue(
225 savedPullRequestSummaryAtom(
226 environmentId === null || reference === null
227 ? ""
228 : savedPullRequestKey(environmentId, reference),
229 ),
230 );
164 useLayoutEffect(() => {231 useLayoutEffect(() => {
165 if (environmentId === null || reference === null || current === null || observedAt === null)232 if (environmentId === null || reference === null || current === null || observedAt === null)
166 return;233 return;
167 observePullRequestSummary(environmentId, reference, current, observedAt);234 observePullRequestSummary(environmentId, reference, current, observedAt);
168 }, [current, environmentId, reference, observedAt]);235 }, [current, environmentId, reference, observedAt]);
169 return (236 const summary =
170 newestPullRequestObservation(237 newestPullRequestObservation(
171 observed,238 observed,
172 current === null || observedAt === null ? null : { summary: current, observedAt },239 current === null || observedAt === null ? null : { summary: current, observedAt },
173 )?.summary ?? current240 )?.summary ?? current;
174 );241 return newestPullRequestSummary(summary, saved);
175}242}
176export const pullRequestStackAtom = createPullRequestStackAtomFamily(243export const pullRequestStackAtom = createPullRequestStackAtomFamily(
177 connectionAtomRuntime,244 connectionAtomRuntime,