authorgravatar for adam.bogucki2018@gmail.comAdamulek123 <adam.bogucki2018@gmail.com> 2026-09-01 13:07:27+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-01 04:07:27-07:00
log3c73fa7ce02b7ee6b2904ec58e47c06262aebf5d
tree8df36971c48515b0e9f7780dbf99662a5f3c681d
parent62d39bf00d5ddd83a9b36a81321fe9aa4d2502bb
signature Signed by PGP key B5690EEEBB952194

perf(web): defer pull request line stats until visible (#6471)

Co-authored-by: Theo Browne <me@t3.gg>

5 files changed, 561 insertions(+), 61 deletions(-)

apps/web/src/components/pullRequest/PullRequestRow.tsx+8-1
......@@ -1,5 +1,5 @@
11import { SearchIcon } from "lucide-react";
2import { memo } from "react";
2import { memo, type RefCallback } from "react";
33
44import { cn } from "~/lib/utils";
55import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";
......@@ -40,6 +40,8 @@ function PullRequestRowImpl({
4040 showProvider,
4141 environmentLabel,
4242 matchedElsewhere,
43 statsKey,
44 statsRef,
4345 onSelect,
4446}: {
4547 entry: EnvironmentPullRequestEntry;
......@@ -54,11 +56,16 @@ function PullRequestRowImpl({
5456 * commit message. Saying so is the difference between a result and an apparently random row.
5557 */
5658 matchedElsewhere?: boolean;
59 /** Used by the list's shared visibility observer to defer optional line-count reads. */
60 statsKey?: string;
61 statsRef?: RefCallback<HTMLButtonElement>;
5762 onSelect: (entry: EnvironmentPullRequestEntry) => void;
5863}) {
5964 const { Icon, providerName } = getSourceControlPresentationForKind(entry.provider);
6065 return (
6166 <button
67 ref={statsRef}
68 data-pull-request-stats-key={statsKey}
6269 type="button"
6370 aria-current={selected ? "true" : undefined}
6471 onClick={() => onSelect(entry)}
apps/web/src/components/pullRequest/pullRequestList.logic.test.ts+196
......@@ -11,6 +11,10 @@ import {
1111 matchesPullRequestFilters,
1212 matchesPullRequestQuery,
1313 parsePullRequestQuery,
14 pullRequestStatsBatches,
15 pullRequestStatsKeysToRequest,
16 pullRequestStatsRefreshBatches,
17 pullRequestStatsRequestBatches,
1418 mergePullRequestDiffStats,
1519 narrowPullRequestsToFilters,
1620 partitionPullRequestsWithPriority,
......@@ -18,6 +22,7 @@ import {
1822 writePullRequestListSnapshot,
1923 rankPullRequestMatches,
2024 scorePullRequestMatch,
25 retainVisiblePullRequestStatsBatches,
2126 withDiffStat,
2227 resolveProjectScope,
2328 resolveQueryEnvironmentIds,
......@@ -56,6 +61,197 @@ function entry(
5661 } as EnvironmentPullRequestEntry;
5762}
5863
64describe("visible pull request line-count targets", () => {
65 it("does not request a row again after its received batch is pruned", () => {
66 const entries = [entry({ number: 1 }), entry({ number: 2 })];
67 const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item]));
68 const firstKey = pullRequestEntryKey(entries[0]!);
69 const secondKey = pullRequestEntryKey(entries[1]!);
70 const completedStats = mergePullRequestDiffStats(new Map(), [
71 {
72 environmentId: ENV_1,
73 projectId: "project-1",
74 number: 1,
75 additions: 1,
76 deletions: 1,
77 },
78 ]);
79
80 const keys = pullRequestStatsKeysToRequest(
81 entriesByKey,
82 new Set([firstKey, secondKey]),
83 [],
84 completedStats,
85 );
86 expect([...keys]).toEqual([secondKey]);
87 expect(pullRequestStatsBatches(entriesByKey, keys)[0]?.input.refs).toEqual([
88 { projectId: "project-1", repository: "pingdotgg/t3code", number: 2 },
89 ]);
90 });
91
92 it("drops historical rows after a long scroll so refresh stays bounded to the viewport", () => {
93 const entries = Array.from({ length: 500 }, (_, index) => entry({ number: index + 1 }));
94 const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item]));
95 const batches = entries.map(
96 (item) => pullRequestStatsBatches(entriesByKey, new Set([pullRequestEntryKey(item)]))[0]!,
97 );
98 const visibleKeys = new Set(entries.slice(-12).map(pullRequestEntryKey));
99
100 const retained = retainVisiblePullRequestStatsBatches(batches, visibleKeys);
101 expect(retained).toHaveLength(12);
102 expect(retained.flatMap((batch) => batch.input.refs.map((ref) => ref.number))).toEqual(
103 entries.slice(-12).map((item) => item.number),
104 );
105 });
106
107 it("keeps every per-environment batch within the stats contract limit", () => {
108 const entries = Array.from({ length: 501 }, (_, index) => entry({ number: index + 1 }));
109 const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item]));
110 const batches = pullRequestStatsBatches(
111 entriesByKey,
112 new Set(entries.map(pullRequestEntryKey)),
113 );
114
115 expect(batches.map((batch) => batch.input.refs.length)).toEqual([500, 1]);
116 expect(batches.flatMap((batch) => [...batch.keys])).toEqual(entries.map(pullRequestEntryKey));
117 });
118
119 it("selects visible rows for date modes and every uncached row for size modes", () => {
120 const entries = [
121 entry({ number: 1 }),
122 entry({ number: 2 }),
123 entry({ number: 3, environmentId: "env-2" as EnvironmentId }),
124 ];
125 const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item]));
126 const [firstKey, secondKey] = entries.map(pullRequestEntryKey);
127 const cachedStats = mergePullRequestDiffStats(new Map(), [
128 {
129 environmentId: ENV_1,
130 projectId: "project-1",
131 number: 1,
132 additions: 1,
133 deletions: 1,
134 },
135 ]);
136
137 const visible = pullRequestStatsRequestBatches({
138 entriesByKey,
139 candidateKeys: new Set([firstKey!, secondKey!]),
140 policy: "visible",
141 activeBatches: [],
142 statsByRow: cachedStats,
143 });
144 expect(visible.flatMap((batch) => batch.input.refs.map((ref) => ref.number))).toEqual([2]);
145
146 const eager = pullRequestStatsRequestBatches({
147 entriesByKey,
148 candidateKeys: new Set([firstKey!]),
149 policy: "eager",
150 activeBatches: [],
151 statsByRow: cachedStats,
152 });
153 expect(eager.map((batch) => batch.environmentId)).toEqual([ENV_1, "env-2"]);
154 expect(eager.flatMap((batch) => batch.input.refs.map((ref) => ref.number))).toEqual([2, 3]);
155 });
156
157 it("refreshes only visible rows unless size sorting needs every loaded row", () => {
158 const entries = [entry({ number: 1 }), entry({ number: 2 }), entry({ number: 3 })];
159 const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item]));
160 const visibleKeys = new Set([pullRequestEntryKey(entries[1]!)]);
161 const cachedStats = mergePullRequestDiffStats(
162 new Map(),
163 entries.map((item) => ({
164 environmentId: ENV_1,
165 projectId: item.projectId,
166 number: item.number,
167 additions: item.additions,
168 deletions: item.deletions,
169 })),
170 );
171
172 const visible = pullRequestStatsRequestBatches({
173 entriesByKey,
174 candidateKeys: visibleKeys,
175 policy: "visible",
176 activeBatches: [],
177 statsByRow: cachedStats,
178 refresh: true,
179 });
180 expect(visible[0]?.input.refs.map((ref) => ref.number)).toEqual([2]);
181
182 const eager = pullRequestStatsRequestBatches({
183 entriesByKey,
184 candidateKeys: visibleKeys,
185 policy: "eager",
186 activeBatches: [],
187 statsByRow: cachedStats,
188 refresh: true,
189 });
190 expect(eager[0]?.input.refs.map((ref) => ref.number)).toEqual([1, 2, 3]);
191 });
192
193 it("ignores a late refresh after the filter or stats policy changes", () => {
194 const entries = [entry({ number: 1 }), entry({ number: 2 })];
195 const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item]));
196 const visibleKeys = new Set([pullRequestEntryKey(entries[1]!)]);
197 const requestedScope = { key: "open", policy: "visible" } as const;
198 const refresh = (currentScope: { key: string; policy: "visible" | "eager" }) =>
199 pullRequestStatsRefreshBatches({
200 requestedScope,
201 currentScope,
202 entriesByKey,
203 candidateKeys: visibleKeys,
204 statsByRow: new Map(),
205 });
206
207 expect(refresh(requestedScope)?.[0]?.input.refs.map((ref) => ref.number)).toEqual([2]);
208 expect(refresh({ key: "closed", policy: "visible" })).toBeNull();
209 expect(refresh({ key: "open", policy: "eager" })).toBeNull();
210 });
211
212 it("does not add request batches again while rows are active or cached", () => {
213 const entries = [entry({ number: 1 }), entry({ number: 2 })];
214 const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item]));
215 const first = pullRequestStatsRequestBatches({
216 entriesByKey,
217 candidateKeys: new Set(),
218 policy: "eager",
219 activeBatches: [],
220 statsByRow: new Map(),
221 });
222
223 expect(
224 pullRequestStatsRequestBatches({
225 entriesByKey,
226 candidateKeys: new Set(),
227 policy: "eager",
228 activeBatches: first,
229 statsByRow: new Map(),
230 }),
231 ).toEqual([]);
232
233 const cachedStats = mergePullRequestDiffStats(
234 new Map(),
235 entries.map((item) => ({
236 environmentId: ENV_1,
237 projectId: item.projectId,
238 number: item.number,
239 additions: item.additions,
240 deletions: item.deletions,
241 })),
242 );
243 expect(
244 pullRequestStatsRequestBatches({
245 entriesByKey,
246 candidateKeys: new Set(entries.map(pullRequestEntryKey)),
247 policy: "visible",
248 activeBatches: [],
249 statsByRow: cachedStats,
250 }),
251 ).toEqual([]);
252 });
253});
254
59255describe("pull request involvement filtering", () => {
60256 const entries = [
61257 entry({ number: 1, author: { login: "Bilal", name: null, avatarUrl: null } }),
apps/web/src/components/pullRequest/pullRequestList.logic.ts+150
......@@ -8,6 +8,7 @@ import {
88 resolvePullRequestAuthorFilter,
99} from "@t3tools/contracts";
1010import type {
11 ProjectId,
1112 PullRequestActor,
1213 PullRequestDiffStat,
1314 PullRequestInvolvement,
......@@ -429,6 +430,155 @@ export function pullRequestEntryKey(entry: ScopedEntry): string {
429430 return `${scope}${entry.host}:${entry.repository}#${entry.number}`;
430431}
431432
433export interface PullRequestStatsTarget {
434 readonly environmentId: EnvironmentId;
435 readonly input: {
436 readonly refs: ReadonlyArray<{
437 readonly projectId: ProjectId;
438 readonly repository: string;
439 readonly number: number;
440 }>;
441 };
442}
443
444export interface PullRequestStatsBatch extends PullRequestStatsTarget {
445 readonly keys: ReadonlySet<string>;
446}
447
448export type PullRequestStatsPolicy = "visible" | "eager";
449
450export interface PullRequestStatsScope {
451 readonly key: string;
452 readonly policy: PullRequestStatsPolicy;
453}
454
455const MAX_PULL_REQUEST_STATS_REFS = 500;
456
457/** Excludes rows already covered by an active batch or the received-count cache. */
458export function pullRequestStatsKeysToRequest(
459 entriesByKey: ReadonlyMap<string, EnvironmentPullRequestEntry>,
460 enteredKeys: ReadonlySet<string>,
461 batches: ReadonlyArray<PullRequestStatsBatch>,
462 statsByRow: ReadonlyMap<string, unknown>,
463): ReadonlySet<string> {
464 const requested = new Set(batches.flatMap((batch) => [...batch.keys]));
465 return new Set(
466 [...enteredKeys].filter((key) => {
467 const entry = entriesByKey.get(key);
468 return (
469 entry !== undefined && !requested.has(key) && !statsByRow.has(pullRequestDiffStatKey(entry))
470 );
471 }),
472 );
473}
474
475/** Groups selected rows into bounded, immutable line-count reads per environment. */
476export function pullRequestStatsBatches(
477 entriesByKey: ReadonlyMap<string, EnvironmentPullRequestEntry>,
478 keys: ReadonlySet<string>,
479): ReadonlyArray<PullRequestStatsBatch> {
480 const byEnvironment = new Map<
481 EnvironmentId,
482 Array<{
483 readonly key: string;
484 readonly ref: PullRequestStatsTarget["input"]["refs"][number];
485 }>
486 >();
487 for (const key of keys) {
488 const entry = entriesByKey.get(key);
489 if (entry === undefined) continue;
490 const rows = byEnvironment.get(entry.environmentId) ?? [];
491 rows.push({
492 key,
493 ref: {
494 projectId: entry.projectId,
495 repository: entry.repository,
496 number: entry.number,
497 },
498 });
499 byEnvironment.set(entry.environmentId, rows);
500 }
501 return [...byEnvironment].flatMap(([environmentId, rows]) => {
502 const batches: PullRequestStatsBatch[] = [];
503 for (let index = 0; index < rows.length; index += MAX_PULL_REQUEST_STATS_REFS) {
504 const batch = rows.slice(index, index + MAX_PULL_REQUEST_STATS_REFS);
505 batches.push({
506 environmentId,
507 input: { refs: batch.map((row) => row.ref) },
508 keys: new Set(batch.map((row) => row.key)),
509 });
510 }
511 return batches;
512 });
513}
514
515/**
516 * Selects the next immutable stats batches. Size sorting needs every loaded row, while the other
517 * modes only need rows near the viewport. Normal reads skip active and cached rows; an explicit
518 * refresh asks for the selected rows again.
519 */
520export function pullRequestStatsRequestBatches({
521 entriesByKey,
522 candidateKeys,
523 policy,
524 activeBatches,
525 statsByRow,
526 refresh = false,
527}: {
528 readonly entriesByKey: ReadonlyMap<string, EnvironmentPullRequestEntry>;
529 readonly candidateKeys: ReadonlySet<string>;
530 readonly policy: PullRequestStatsPolicy;
531 readonly activeBatches: ReadonlyArray<PullRequestStatsBatch>;
532 readonly statsByRow: ReadonlyMap<string, unknown>;
533 readonly refresh?: boolean;
534}): ReadonlyArray<PullRequestStatsBatch> {
535 const requestedKeys = policy === "eager" ? new Set(entriesByKey.keys()) : candidateKeys;
536 const keys = refresh
537 ? requestedKeys
538 : pullRequestStatsKeysToRequest(entriesByKey, requestedKeys, activeBatches, statsByRow);
539 return pullRequestStatsBatches(entriesByKey, keys);
540}
541
542/** Ignores a refresh that finished after the list moved to another filter or stats policy. */
543export function pullRequestStatsRefreshBatches({
544 requestedScope,
545 currentScope,
546 entriesByKey,
547 candidateKeys,
548 statsByRow,
549}: {
550 readonly requestedScope: PullRequestStatsScope;
551 readonly currentScope: PullRequestStatsScope;
552 readonly entriesByKey: ReadonlyMap<string, EnvironmentPullRequestEntry>;
553 readonly candidateKeys: ReadonlySet<string>;
554 readonly statsByRow: ReadonlyMap<string, unknown>;
555}): ReadonlyArray<PullRequestStatsBatch> | null {
556 if (requestedScope.key !== currentScope.key || requestedScope.policy !== currentScope.policy) {
557 return null;
558 }
559 return pullRequestStatsRequestBatches({
560 entriesByKey,
561 candidateKeys,
562 policy: requestedScope.policy,
563 activeBatches: [],
564 statsByRow,
565 refresh: true,
566 });
567}
568
569/** Drops completed batches once every row in them has left the observer window. */
570export function retainVisiblePullRequestStatsBatches(
571 batches: ReadonlyArray<PullRequestStatsBatch>,
572 visibleKeys: ReadonlySet<string>,
573): ReadonlyArray<PullRequestStatsBatch> {
574 return batches.filter((batch) => {
575 for (const key of batch.keys) {
576 if (visibleKeys.has(key)) return true;
577 }
578 return false;
579 });
580}
581
432582/**
433583 * The priority groups built from the hosts' own answers rather than re-partitioned from the
434584 * paginated feed. The feed is sliced by recency, so an older authored or review-requested row
apps/web/src/routes/_chat.pull-requests.tsx+191-52
......@@ -32,7 +32,15 @@ import {
3232 RefreshCwIcon,
3333 SearchIcon,
3434} from "lucide-react";
35import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
35import {
36 useCallback,
37 useEffect,
38 useMemo,
39 useRef,
40 useState,
41 type ReactNode,
42 type RefObject,
43} from "react";
3644
3745import {
3846 filterPullRequestsByInvolvement,
......@@ -57,9 +65,15 @@ import {
5765 withDiffStat,
5866 writePullRequestListSnapshot,
5967 scorePullRequestMatch,
68 pullRequestStatsRefreshBatches,
69 pullRequestStatsRequestBatches,
70 retainVisiblePullRequestStatsBatches,
6071 type EnvironmentPullRequestEntry,
6172 type MergedPullRequestList,
6273 type PullRequestDiffStats,
74 type PullRequestStatsBatch,
75 type PullRequestStatsPolicy,
76 type PullRequestStatsScope,
6377 type PullRequestPartitionsSnapshot,
6478} from "../components/pullRequest/pullRequestList.logic";
6579import { assignProjectsToEnvironments } from "../components/pullRequest/pullRequestProjectAssignment.logic";
......@@ -273,6 +287,8 @@ export const Route = createFileRoute("/_chat/pull-requests")({
273287function PullRequestsRouteView() {
274288 const search = Route.useSearch();
275289 const sort = search.sort ?? "updated";
290 const statsPolicy: PullRequestStatsPolicy =
291 sort === "largest" || sort === "smallest" ? "eager" : "visible";
276292 const navigate = useNavigate({ from: Route.fullPath });
277293 const { environments } = useEnvironments();
278294 // Every connected environment that has said it can list pull requests. Sorted, so the query
......@@ -607,6 +623,8 @@ function PullRequestsRouteView() {
607623 // Page size is view state, not a URL concern: a shared link should open the first page.
608624 const scopeKey = `${environmentKey}:${assignmentKey}:${search.state}:${search.involvement}:${scopedProjectId ?? ""}:${search.host ?? ""}:${search.draft ?? ""}:${search.review ?? ""}:${search.checks ?? ""}:${search.author ?? ""}:${search.labels?.join("\u0000") ?? ""}`;
609625 const filterKey = `${scopeKey}:${sentQuery}`;
626 const statsScopeRef = useRef<PullRequestStatsScope>({ key: filterKey, policy: statsPolicy });
627 statsScopeRef.current = { key: filterKey, policy: statsPolicy };
610628 // Where the next slice carries on from, per repository within each environment, as that
611629 // environment handed it back. Sending it is what makes a second page cost a second page rather
612630 // than the whole list again — and a repository it does not name has run out and is not read a
......@@ -780,6 +798,7 @@ function PullRequestsRouteView() {
780798 // of its own work is a button that gets pressed again, and buys the whole cascade twice.
781799 const [invalidating, setInvalidating] = useState(false);
782800 const refreshFromHost = async () => {
801 const requestedStatsScope = statsScopeRef.current;
783802 setInvalidating(true);
784803 try {
785804 // Every environment the page is reading, since what the reader pressed refresh for is the
......@@ -795,7 +814,18 @@ function PullRequestsRouteView() {
795814 facetQuery.refresh();
796815 authoredQuery.refresh();
797816 reviewingQuery.refresh();
798 statsQuery.refresh();
817 const visible = visibleStatsKeys.current;
818 const batches = pullRequestStatsRefreshBatches({
819 requestedScope: requestedStatsScope,
820 currentScope: statsScopeRef.current,
821 entriesByKey: entriesByStatsKey.current,
822 candidateKeys: visible.key === requestedStatsScope.key ? visible.values : new Set(),
823 statsByRow: statsByRowRef.current,
824 });
825 if (batches !== null) {
826 setStatsTargetState({ key: requestedStatsScope.key, batches });
827 statsQuery.refresh(batches.map(({ environmentId, input }) => ({ environmentId, input })));
828 }
799829 setDetailRefreshToken((token) => token + 1);
800830 };
801831 const refreshing = invalidating || listQuery.isPending;
......@@ -1122,6 +1152,7 @@ function PullRequestsRouteView() {
11221152 viewers,
11231153 ]);
11241154
1155 const scrollRef = useRef<HTMLDivElement>(null);
11251156 const sentinelRef = useRef<HTMLDivElement>(null);
11261157 useEffect(() => {
11271158 const sentinel = sentinelRef.current;
......@@ -1156,7 +1187,7 @@ function PullRequestsRouteView() {
11561187 }
11571188 },
11581189 // Start the next page slightly before the sentinel is on screen.
1159 { rootMargin: "240px" },
1190 { root: scrollRef.current, rootMargin: "240px" },
11601191 );
11611192 observer.observe(sentinel);
11621193 return () => observer.disconnect();
......@@ -1220,35 +1251,136 @@ function PullRequestsRouteView() {
12201251 viewers,
12211252 ]);
12221253
1223 // Keyed by every row being shown — the partitions can hold rows the feed has not paged to —
1224 // so scrolling further asks only about what is new. One read per environment, each asking only
1225 // about its own rows: a reference names a project, and a project belongs to one machine.
1226 const statsTargets = useMemo(() => {
1227 const refsByEnvironment = new Map<
1228 EnvironmentId,
1229 Array<{ projectId: ProjectId; repository: string; number: number }>
1230 >();
1231 for (const group of groups) {
1232 for (const entry of group.entries) {
1233 const refs = refsByEnvironment.get(entry.environmentId) ?? [];
1234 refs.push({
1235 projectId: entry.projectId,
1236 repository: entry.repository,
1237 number: entry.number,
1238 });
1239 refsByEnvironment.set(entry.environmentId, refs);
1254 // Date sorts keep optional line-count reads near the viewport. Size sorts need every loaded
1255 // count before their order is final. Received counts stay cached across both policies.
1256 const entriesByStatsKey = useRef<ReadonlyMap<string, EnvironmentPullRequestEntry>>(new Map());
1257 entriesByStatsKey.current = new Map(
1258 groups.flatMap((group) =>
1259 group.entries.map((entry) => [pullRequestEntryKey(entry), entry] as const),
1260 ),
1261 );
1262 const visibleStatsKeys = useRef({ key: filterKey, values: new Set<string>() });
1263 const [statsByRow, setStatsByRow] = useState<PullRequestDiffStats>(() => new Map());
1264 const statsByRowRef = useRef(statsByRow);
1265 statsByRowRef.current = statsByRow;
1266 const [statsTargetState, setStatsTargetState] = useState<{
1267 readonly key: string;
1268 readonly batches: ReadonlyArray<PullRequestStatsBatch>;
1269 }>({ key: filterKey, batches: [] });
1270 const statsBatches = statsTargetState.key === filterKey ? statsTargetState.batches : [];
1271 const statsTargets = useMemo(
1272 () =>
1273 statsBatches.map(({ environmentId, input }) => ({
1274 environmentId,
1275 input,
1276 })),
1277 [statsBatches],
1278 );
1279 const statsObserver = useRef<IntersectionObserver | null>(null);
1280 const statsRows = useRef(new Set<HTMLButtonElement>());
1281 const statsPending = useRef(true);
1282 const statsPolicyRef = useRef(statsPolicy);
1283 statsPolicyRef.current = statsPolicy;
1284 const registerStatsRow = useCallback((node: HTMLButtonElement | null) => {
1285 if (node === null || typeof IntersectionObserver === "undefined") return;
1286 statsRows.current.add(node);
1287 statsObserver.current?.observe(node);
1288 return () => {
1289 statsRows.current.delete(node);
1290 statsObserver.current?.unobserve(node);
1291 const key = node.dataset.pullRequestStatsKey;
1292 const visible = visibleStatsKeys.current;
1293 if (
1294 statsPolicyRef.current !== "visible" ||
1295 key === undefined ||
1296 !visible.values.delete(key) ||
1297 statsPending.current
1298 ) {
1299 return;
12401300 }
1241 }
1242 return [...refsByEnvironment].map(([environmentId, refs]) => ({
1243 environmentId,
1244 input: { refs },
1245 }));
1246 }, [groups]);
1301 setStatsTargetState((current) => {
1302 if (current.key !== visible.key) return current;
1303 const batches = retainVisiblePullRequestStatsBatches(current.batches, visible.values);
1304 return batches.length === current.batches.length ? current : { key: current.key, batches };
1305 });
1306 };
1307 }, []);
1308 useEffect(() => {
1309 if (statsPolicy !== "eager") return;
1310 setStatsTargetState((current) => {
1311 const batches = current.key === filterKey ? current.batches : [];
1312 const added = pullRequestStatsRequestBatches({
1313 entriesByKey: entriesByStatsKey.current,
1314 candidateKeys: visibleStatsKeys.current.values,
1315 policy: statsPolicy,
1316 activeBatches: batches,
1317 statsByRow: statsByRowRef.current,
1318 });
1319 if (added.length === 0 && current.key === filterKey) return current;
1320 return { key: filterKey, batches: [...batches, ...added] };
1321 });
1322 }, [filterKey, groups, statsPolicy]);
1323 useEffect(() => {
1324 if (statsPolicy !== "visible" || typeof IntersectionObserver === "undefined") return;
1325 visibleStatsKeys.current = { key: filterKey, values: new Set() };
1326 const observer = new IntersectionObserver(
1327 (observed) => {
1328 const visible = visibleStatsKeys.current;
1329 if (visible.key !== filterKey) return;
1330 let changed = false;
1331 const entered = new Set<string>();
1332 for (const item of observed) {
1333 const key = (item.target as HTMLElement).dataset.pullRequestStatsKey;
1334 if (key === undefined) continue;
1335 const wasVisible = visible.values.has(key);
1336 if (item.isIntersecting && entriesByStatsKey.current.has(key)) {
1337 visible.values.add(key);
1338 if (!wasVisible) entered.add(key);
1339 } else {
1340 visible.values.delete(key);
1341 }
1342 changed ||= wasVisible !== visible.values.has(key);
1343 }
1344 if (!changed) return;
1345 setStatsTargetState((current) => {
1346 const batches = current.key === filterKey ? current.batches : [];
1347 const retained = statsPending.current
1348 ? batches
1349 : retainVisiblePullRequestStatsBatches(batches, visible.values);
1350 const added = pullRequestStatsRequestBatches({
1351 entriesByKey: entriesByStatsKey.current,
1352 candidateKeys: entered,
1353 policy: statsPolicy,
1354 activeBatches: batches,
1355 statsByRow: statsByRowRef.current,
1356 });
1357 if (added.length === 0 && retained.length === batches.length) return current;
1358 return { key: filterKey, batches: [...retained, ...added] };
1359 });
1360 },
1361 { root: scrollRef.current, rootMargin: "480px" },
1362 );
1363 statsObserver.current = observer;
1364 for (const row of statsRows.current) observer.observe(row);
1365 return () => {
1366 observer.disconnect();
1367 if (statsObserver.current === observer) statsObserver.current = null;
1368 };
1369 }, [filterKey, statsPolicy]);
12471370 const statsQuery = usePullRequestListStats(statsTargets);
1371 statsPending.current = statsQuery.isPending;
1372 useEffect(() => {
1373 if (statsPolicy !== "visible" || statsQuery.isPending) return;
1374 const visible = visibleStatsKeys.current;
1375 setStatsTargetState((current) => {
1376 if (current.key !== filterKey || visible.key !== filterKey) return current;
1377 const batches = retainVisiblePullRequestStatsBatches(current.batches, visible.values);
1378 return batches.length === current.batches.length ? current : { key: current.key, batches };
1379 });
1380 }, [filterKey, statsPolicy, statsQuery.isPending]);
12481381 // Adding or removing one row keys a fresh stats query with nothing in it yet, so the counts
12491382 // are merged into what is already held rather than rebuilt: every count on screen stays until
12501383 // its replacement arrives.
1251 const [statsByRow, setStatsByRow] = useState<PullRequestDiffStats>(() => new Map());
12521384 useEffect(() => {
12531385 const stats = statsQuery.stats;
12541386 if (stats === null) return;
......@@ -1486,30 +1618,35 @@ function PullRequestsRouteView() {
14861618 {group.label}
14871619 </h2>
14881620 ) : null}
1489 {group.entries.map((entry) => (
1490 <PullRequestRow
1491 key={pullRequestEntryKey(entry)}
1492 entry={entry}
1493 showProjectTitle
1494 showProvider={showProvider}
1495 {...(capableEnvironments.length > 1 &&
1496 environmentLabels.get(entry.environmentId) !== undefined
1497 ? { environmentLabel: environmentLabels.get(entry.environmentId)! }
1498 : {})}
1499 // Ten is the floor the ranking gives a row whose own fields say nothing
1500 // about the search: the host matched something this row cannot show.
1501 matchedElsewhere={
1502 typedParsed.text.length > 0 &&
1503 scorePullRequestMatch(entry, typedParsed.text) <= MATCHED_ELSEWHERE_SCORE
1504 }
1505 selected={
1506 selected?.environmentId === entry.environmentId &&
1507 selected.repository === entry.repository &&
1508 selected.number === entry.number
1509 }
1510 onSelect={selectEntry}
1511 />
1512 ))}
1621 {group.entries.map((entry) => {
1622 const entryKey = pullRequestEntryKey(entry);
1623 return (
1624 <PullRequestRow
1625 key={entryKey}
1626 statsKey={entryKey}
1627 statsRef={registerStatsRow}
1628 entry={entry}
1629 showProjectTitle
1630 showProvider={showProvider}
1631 {...(capableEnvironments.length > 1 &&
1632 environmentLabels.get(entry.environmentId) !== undefined
1633 ? { environmentLabel: environmentLabels.get(entry.environmentId)! }
1634 : {})}
1635 // Ten is the floor the ranking gives a row whose own fields say nothing
1636 // about the search: the host matched something this row cannot show.
1637 matchedElsewhere={
1638 typedParsed.text.length > 0 &&
1639 scorePullRequestMatch(entry, typedParsed.text) <= MATCHED_ELSEWHERE_SCORE
1640 }
1641 selected={
1642 selected?.environmentId === entry.environmentId &&
1643 selected.repository === entry.repository &&
1644 selected.number === entry.number
1645 }
1646 onSelect={selectEntry}
1647 />
1648 );
1649 })}
15131650 </div>
15141651 ))}
15151652 </div>
......@@ -1648,6 +1785,7 @@ function PullRequestsRouteView() {
16481785 pullRequestsSupported && !rightPanelState.isOpen ? openPanelControls : null,
16491786 rightPanelOpen: rightPanelState.isOpen,
16501787 listBody,
1788 scrollRef,
16511789 };
16521790
16531791 const activateSurface = (surface: PullRequestSurface) => {
......@@ -1932,6 +2070,7 @@ function PullRequestsColumn({
19322070 titlebarControls,
19332071 rightPanelOpen,
19342072 listBody,
2073 scrollRef,
19352074}: {
19362075 refreshing: boolean;
19372076 onRefresh: () => void;
......@@ -1950,8 +2089,8 @@ function PullRequestsColumn({
19502089 titlebarControls: ReactNode;
19512090 rightPanelOpen: boolean;
19522091 listBody: ReactNode;
2092 scrollRef: RefObject<HTMLDivElement | null>;
19532093}) {
1954 const scrollRef = useRef<HTMLDivElement | null>(null);
19552094 const markerRef = useRef<HTMLDivElement | null>(null);
19562095 const [condensed, setCondensed] = useState(false);
19572096 useEffect(() => {
apps/web/src/state/pullRequests.ts+16-8
......@@ -31,7 +31,7 @@ export interface EnvironmentQueryTarget<Input> {
3131}
3232
3333interface MergedEnvironmentQueryView<A> {
34 /** One entry per environment that has answered, in the order the targets were given. */
34 /** One entry per query target that has answered, in the order the targets were given. */
3535 readonly values: ReadonlyArray<readonly [EnvironmentId, A]>;
3636 /** The first environment that failed. Others may still have answered — this is not fatal. */
3737 readonly error: string | null;
......@@ -78,11 +78,16 @@ function createMergedEnvironmentQuery<Input, A>(
7878 return function useMergedQuery(targets: ReadonlyArray<EnvironmentQueryTarget<Input>>) {
7979 const key = JSON.stringify(targets);
8080 const view = useAtomValue(targets.length === 0 ? empty : family(key));
81 const refresh = useCallback(() => {
82 for (const target of JSON.parse(key) as ReadonlyArray<EnvironmentQueryTarget<Input>>) {
83 appAtomRegistry.refresh(atomFor(target));
84 }
85 }, [key]);
81 const refresh = useCallback(
82 (override?: ReadonlyArray<EnvironmentQueryTarget<Input>>) => {
83 const refreshTargets =
84 override ?? (JSON.parse(key) as ReadonlyArray<EnvironmentQueryTarget<Input>>);
85 for (const target of refreshTargets) {
86 appAtomRegistry.refresh(atomFor(target));
87 }
88 },
89 [key],
90 );
8691 return { ...view, refresh };
8792 };
8893}
......@@ -118,7 +123,10 @@ export function usePullRequestListStats(
118123 targets: ReadonlyArray<EnvironmentQueryTarget<PullRequestListStatsInput>>,
119124): {
120125 readonly stats: ReadonlyArray<EnvironmentPullRequestStat> | null;
121 readonly refresh: () => void;
126 readonly isPending: boolean;
127 readonly refresh: (
128 targets?: ReadonlyArray<EnvironmentQueryTarget<PullRequestListStatsInput>>,
129 ) => void;
122130} {
123131 const query = usePullRequestStatsQuery(targets);
124132 const stats = useMemo(
......@@ -130,5 +138,5 @@ export function usePullRequestListStats(
130138 ),
131139 [query.values],
132140 );
133 return { stats, refresh: query.refresh };
141 return { stats, isPending: query.isPending, refresh: query.refresh };
134142}