Skip to content
Merged
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
37 changes: 37 additions & 0 deletions apps/mobile/src/features/pull-requests/pullRequestList.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import {
appendPullRequestListPage,
DEFAULT_PR_LIST_CONTROLS,
EMPTY_PULL_REQUEST_STATS_CACHE,
filterPullRequestList,
mergePullRequestListEntries,
mergeEnvironmentPullRequestPages,
Expand All @@ -21,6 +22,8 @@ import {
scopedPullRequestListKey,
shownPullRequestListPages,
pullRequestListRowsAreEqual,
withAnsweredPullRequestStats,
withListedPullRequests,
withShownPullRequestStats,
withoutScratchProjectScope,
} from "./pullRequestList";
Expand Down Expand Up @@ -435,3 +438,37 @@ describe("shown PR diff stats", () => {
expect(filled[1]).toBe(rows[1]);
});
});

describe("cached PR diff stats", () => {
const stat = (number: number, additions: number) => ({
projectId: "project" as PullRequestListEntry["projectId"],
repository: "org/repo",
number,
additions,
deletions: 0,
});

it("drops a row's stat and refreshes its stats query when its updatedAt moves", () => {
const query = {
refs: [1, 2].map((number) => ({
projectId: stat(number, 0).projectId,
repository: "org/repo",
number,
})),
};
const listed = withListedPullRequests(EMPTY_PULL_REQUEST_STATS_CACHE, [entry(1), entry(2)]);
expect(listed.refresh).toEqual([]);
const answered = withAnsweredPullRequestStats(listed.cache, query, [stat(1, 5), stat(2, 7)]);
expect(withListedPullRequests(answered, [entry(1), entry(2)])).toEqual({
cache: answered,
refresh: [],
});

const pushed = withListedPullRequests(answered, [
entry(1, { updatedAt: "2026-10-20T00:00:00Z" }),
entry(2),
]);
expect([...pushed.cache.stats.values()]).toEqual([stat(2, 7)]);
expect(pushed.refresh).toEqual([query]);
});
});
66 changes: 66 additions & 0 deletions apps/mobile/src/features/pull-requests/pullRequestList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type PullRequestListEntry,
type PullRequestListInput,
type PullRequestListResult,
type PullRequestListStatsInput,
} from "@t3tools/contracts";
import type { PullRequestListControls } from "../../persistence/mobile-preferences";

Expand Down Expand Up @@ -50,6 +51,71 @@ export const pullRequestStatKey = (
row: Pick<PullRequestDiffStat, "projectId" | "repository" | "number">,
) => `${row.projectId}:${row.repository}:${row.number}`;

/**
* Diff stats an environment has answered, the stats query that answered each, and each row's
* `updatedAt` as its list last answered it.
*/
export interface PullRequestStatsCache {
readonly stats: ReadonlyMap<string, PullRequestDiffStat>;
readonly answeredBy: ReadonlyMap<string, PullRequestListStatsInput>;
readonly updatedAt: ReadonlyMap<string, string>;
}

export const EMPTY_PULL_REQUEST_STATS_CACHE: PullRequestStatsCache = {
stats: new Map(),
answeredBy: new Map(),
updatedAt: new Map(),
};

/**
* Records the rows a list answered. Stats carry no head, so a row whose `updatedAt` moved (a push,
* among other changes) drops its cached stat, and the stats queries that answered it are returned
* to refresh: one can still be fresh and would otherwise answer the row's next read with the old
* counts.
*/
export function withListedPullRequests(
cache: PullRequestStatsCache,
entries: readonly Pick<
PullRequestListEntry,
"projectId" | "repository" | "number" | "updatedAt"
>[],
): {
readonly cache: PullRequestStatsCache;
readonly refresh: readonly PullRequestListStatsInput[];
} {
const moved = entries.filter(
(entry) => cache.updatedAt.get(pullRequestStatKey(entry)) !== entry.updatedAt,
);
if (!moved.length) return { cache, refresh: [] };
const stats = new Map(cache.stats);
const answeredBy = new Map(cache.answeredBy);
const updatedAt = new Map(cache.updatedAt);
const refresh = new Set<PullRequestListStatsInput>();
for (const entry of moved) {
const key = pullRequestStatKey(entry);
const query = answeredBy.get(key);
if (query) refresh.add(query);
stats.delete(key);
answeredBy.delete(key);
updatedAt.set(key, entry.updatedAt);
}
return { cache: { stats, answeredBy, updatedAt }, refresh: [...refresh] };
}

export function withAnsweredPullRequestStats(
cache: PullRequestStatsCache,
input: PullRequestListStatsInput,
answered: readonly PullRequestDiffStat[],
): PullRequestStatsCache {
const stats = new Map(cache.stats);
const answeredBy = new Map(cache.answeredBy);
for (const row of answered) {
stats.set(pullRequestStatKey(row), row);
answeredBy.set(pullRequestStatKey(row), input);
}
return { ...cache, stats, answeredBy };
}

/**
* Fills rows that have no diff stat yet from the stats answers on screen. Returns `entries`
* itself when no row changes, so the list keeps its identity across renders.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ export function usePullRequestList(targets: readonly ListTarget[]) {
pages: {
...pages,
entries: pages.entries.map((entry) => {
const stat = stats.get(statKey(entry));
const stat = stats.stats.get(statKey(entry));
return stat
? { ...entry, additions: stat.additions, deletions: stat.deletions }
: entry;
Expand Down
39 changes: 24 additions & 15 deletions apps/mobile/src/state/pull-requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,18 @@ import {
createPullRequestEnvironmentAtoms,
createPullRequestStackAtomFamily,
} from "@t3tools/client-runtime/state/pull-requests";
import type { EnvironmentId, PullRequestDiffStat, PullRequestListInput } from "@t3tools/contracts";
import type { EnvironmentId, PullRequestListInput } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import { AsyncResult, Atom } from "effect/reactivity";
import { connectionAtomRuntime } from "../connection/runtime";
import {
EMPTY_PULL_REQUEST_STATS_CACHE,
pullRequestListHead,
pullRequestStatKey,
withAnsweredPullRequestStats,
withListedPullRequests,
type PullRequestListPages,
type PullRequestStatsCache,
} from "../features/pull-requests/pullRequestList";

export const pullRequestListPagesKey = (target: {
Expand All @@ -25,7 +28,9 @@ export const pullRequestListPagesFamily = Atom.family((_key: string) =>

/** Diff stats each environment has answered so far, so rows keep them when they scroll away. */
export const pullRequestListStatsFamily = Atom.family((_environmentId: string) =>
Atom.make<ReadonlyMap<string, PullRequestDiffStat>>(new Map()).pipe(Atom.setIdleTTL(5 * 60_000)),
Atom.make<PullRequestStatsCache>(EMPTY_PULL_REQUEST_STATS_CACHE).pipe(
Atom.setIdleTTL(5 * 60_000),
),
);

export const composerPullRequests = createPullRequestEnvironmentAtoms(connectionAtomRuntime, {
Expand All @@ -38,20 +43,24 @@ export const composerPullRequests = createPullRequestEnvironmentAtoms(connection
registry.refresh(composerPullRequests.filesViewed(target));
}),
list: (target, value, registry) =>
target.input.cursors === undefined
? Effect.sync(() =>
registry.update(pullRequestListPagesFamily(pullRequestListPagesKey(target)), (previous) =>
pullRequestListHead(previous, value),
),
)
: Effect.void,
Effect.sync(() => {
const statsAtom = pullRequestListStatsFamily(target.environmentId);
const { cache, refresh } = withListedPullRequests(registry.get(statsAtom), value.entries);
registry.set(statsAtom, cache);
for (const input of refresh)
registry.refresh(
composerPullRequests.listStats({ environmentId: target.environmentId, input }),
);
if (target.input.cursors === undefined)
registry.update(pullRequestListPagesFamily(pullRequestListPagesKey(target)), (previous) =>
pullRequestListHead(previous, value),
);
}),
listStats: (target, value, registry) =>
Effect.sync(() =>
registry.update(pullRequestListStatsFamily(target.environmentId), (previous) => {
const next = new Map(previous);
for (const row of value.stats) next.set(pullRequestStatKey(row), row);
return next;
}),
registry.update(pullRequestListStatsFamily(target.environmentId), (previous) =>
withAnsweredPullRequestStats(previous, target.input, value.stats),
),
),
});
export const pullRequestStackAtom = createPullRequestStackAtomFamily(
Expand Down
Loading