diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 66257a6c3af9..8f8faa93a867 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -3643,6 +3643,340 @@ it.effect("answers a known pull request immediately while the host refreshes", ( }), ); +it.effect("an invalidated detail read sees an external change a plain re-read holds back", () => + Effect.gen(function* () { + const gate = yield* Deferred.make(); + let calls = 0; + let hostTitle = "old title"; + let hostChecks: ReadonlyArray<{ readonly name: string; readonly status: "success" }> = []; + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequest: () => + Effect.gen(function* () { + calls += 1; + // The plain re-read's background refresh stalls here, so the hold stays old + // while the invalidated poll read runs past it. + if (calls === 2) yield* Deferred.await(gate); + return { + ...hostedChangeRequest("polled body", 4), + title: hostTitle, + checks: hostChecks.map((check) => ({ + name: check.name, + status: check.status, + description: null, + url: null, + })), + }; + }), + }), + ], + }); + + const first = yield* service.detail(reference); + assert.strictEqual(first.title, "old title"); + assert.deepStrictEqual(first.checks, []); + + hostTitle = "new title"; + hostChecks = [{ name: "ci", status: "success" }]; + yield* TestClock.adjust("16 seconds"); + + // The old poll path: a plain re-read answers from the hold while refreshing behind it. + const second = yield* service.detail(reference); + assert.strictEqual(second.title, "old title"); + assert.deepStrictEqual(second.checks, []); + yield* Effect.yieldNow; + assert.strictEqual(calls, 2); + + // The poll path the panel now takes: invalidate first so the re-read misses the hold, + // even with that background refresh still in flight. + yield* service.invalidate({ reference }); + const polled = yield* service.detail(reference); + assert.strictEqual(polled.title, "new title"); + assert.deepStrictEqual( + polled.checks.map((check) => check.name), + ["ci"], + ); + + yield* Deferred.succeed(gate, undefined); + yield* Effect.yieldNow; + }), +); + +it.effect("a detail-scoped invalidate refreshes detail without stranding the held diff", () => + Effect.gen(function* () { + const diffGate = yield* Deferred.make(); + let detailCalls = 0; + let diffCalls = 0; + let hostTitle = "old title"; + let hostPatch = "old patch"; + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequest: () => + Effect.sync(() => { + detailCalls += 1; + return { ...hostedChangeRequest("polled body", 4), title: hostTitle }; + }), + getDiff: () => + Effect.gen(function* () { + diffCalls += 1; + // The stale-while-revalidate background refresh stalls here, so a held diff + // must answer from its snapshot rather than wait on the host. + if (diffCalls === 2) yield* Deferred.await(diffGate); + return { patch: hostPatch, truncated: false, nextCursor: null }; + }), + }), + ], + }); + + const firstDetail = yield* service.detail(reference); + assert.strictEqual(firstDetail.title, "old title"); + const firstDiff = yield* service.diff(reference); + assert.strictEqual(firstDiff.patch, "old patch"); + assert.strictEqual(diffCalls, 1); + + hostTitle = "new title"; + hostPatch = "new patch"; + // Past the diff cache TTL but inside the stale-while-revalidate window, so a held diff + // answers from its snapshot while refreshing behind it. + yield* TestClock.adjust("61 seconds"); + + // The poll path: detail misses the hold while the diff key — and its hold — is untouched. + yield* service.invalidate({ reference, scope: "detail" }); + const polledDetail = yield* service.detail(reference); + assert.strictEqual(polledDetail.title, "new title"); + assert.strictEqual(detailCalls, 2); + + const polledDiff = yield* service.diff(reference); + assert.strictEqual(polledDiff.patch, "old patch"); + yield* Effect.yieldNow; + assert.strictEqual(diffCalls, 2); + + yield* Deferred.succeed(diffGate, undefined); + yield* Effect.yieldNow; + }), +); + +it.effect("recently read scopes retain held responses when epoch maps reach capacity", () => + Effect.gen(function* () { + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + let failHost = false; + const failure = new PullRequestProviderError({ + provider: "github", + operation: "read", + reason: "failed", + detail: "HTTP 503", + }); + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequest: () => + failHost ? Effect.fail(failure) : Effect.succeed(hostedChangeRequest("held body", 4)), + getDiff: () => + failHost + ? Effect.fail(failure) + : Effect.succeed({ patch: "held patch", truncated: false, nextCursor: null }), + }), + ], + }); + const heldDetail = yield* service.detail(reference); + yield* service.diff(reference); + for (let number = 2; number <= 2_048; number += 1) { + yield* service.invalidate({ reference: { ...reference, number } }); + } + // Reading the active scope keeps its generations newer than the untouched scopes. + yield* service.detail(reference); + yield* service.diff(reference); + yield* service.invalidate({ reference: { ...reference, number: 2_049 } }); + failHost = true; + yield* TestClock.adjust("61 seconds"); + + assert.deepStrictEqual(yield* service.detail(reference), heldDetail); + assert.strictEqual((yield* service.diff(reference)).patch, "held patch"); + }), +); + +for (const afterTurn of [false, true]) { + it.effect( + `evicting invalidation epochs never revives held responses (after turn: ${afterTurn})`, + () => + Effect.gen(function* () { + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + let version = "old"; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakeProvider("github", { + getChangeRequest: () => + Effect.succeed({ ...hostedChangeRequest("body", 4), title: version }), + getDiff: (input) => + Effect.succeed({ + patch: `${version}:${input.cursor ?? "first"}`, + truncated: false, + nextCursor: input.cursor ? null : "page-2", + }), + }), + ], + }); + + assert.strictEqual((yield* service.detail(reference)).title, "old"); + assert.strictEqual((yield* service.diff(reference)).patch, "old:first"); + assert.strictEqual( + (yield* service.diff({ ...reference, cursor: "page-2" })).patch, + "old:page-2", + ); + if (afterTurn) { + yield* service.refreshAfterTurn; + yield* service.detail(reference); + yield* service.diff(reference); + yield* service.diff({ ...reference, cursor: "page-2" }); + } + version = "new"; + yield* service.invalidate({ reference }); + // Fill the bounded epoch maps with other scopes without evicting the held responses. + for (let number = 2; number <= 2_049; number += 1) { + yield* service.invalidate({ reference: { ...reference, number } }); + } + + assert.strictEqual((yield* service.detail(reference)).title, "new"); + assert.strictEqual((yield* service.diff(reference)).patch, "new:first"); + assert.strictEqual( + (yield* service.diff({ ...reference, cursor: "page-2" })).patch, + "new:page-2", + ); + }), + ); +} + +it.effect("a changed updatedAt reloads every held diff page after detail-only invalidation", () => + Effect.gen(function* () { + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const calls: Array = []; + let updatedAt = "2026-07-02T00:00:00Z"; + let patchVersion = "old"; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequest: () => Effect.succeed({ ...hostedChangeRequest("body", 4), updatedAt }), + getDiff: (input) => + Effect.sync(() => { + calls.push(input.cursor); + return { + patch: `${patchVersion}:${input.cursor ?? "first"}`, + truncated: false, + nextCursor: input.cursor ? null : "page-2", + }; + }), + }), + ], + }); + + yield* service.detail(reference); + assert.strictEqual((yield* service.diff(reference)).patch, "old:first"); + assert.strictEqual( + (yield* service.diff({ ...reference, cursor: "page-2" })).patch, + "old:page-2", + ); + + patchVersion = "new"; + yield* service.invalidate({ reference, scope: "detail" }); + yield* service.detail(reference); + assert.strictEqual((yield* service.diff(reference)).patch, "old:first"); + assert.strictEqual( + (yield* service.diff({ ...reference, cursor: "page-2" })).patch, + "old:page-2", + ); + assert.deepStrictEqual(calls, [undefined, "page-2"]); + + updatedAt = "2026-07-03T00:00:00Z"; + yield* service.invalidate({ reference, scope: "detail" }); + assert.strictEqual((yield* service.detail(reference)).updatedAt, updatedAt); + assert.strictEqual((yield* service.diff(reference)).patch, "new:first"); + assert.strictEqual( + (yield* service.diff({ ...reference, cursor: "page-2" })).patch, + "new:page-2", + ); + assert.deepStrictEqual(calls, [undefined, "page-2", undefined, "page-2"]); + }), +); + +it.effect("full invalidation reloads every held diff page after a failed detail refresh", () => + Effect.gen(function* () { + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const calls: Array = []; + let revision = "old"; + let failDetail = false; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequest: () => + failDetail + ? Effect.fail( + new PullRequestProviderError({ + provider: "github", + operation: "getChangeRequest", + reason: "failed", + detail: "HTTP 503", + }), + ) + : Effect.succeed(hostedChangeRequest("body", 4)), + getDiff: (input) => + Effect.sync(() => { + calls.push(input.cursor); + return { + patch: `${revision}:${input.cursor ?? "first"}`, + truncated: false, + nextCursor: input.cursor ? null : "page-2", + }; + }), + }), + ], + }); + yield* service.detail(reference); + assert.strictEqual((yield* service.diff(reference)).patch, "old:first"); + assert.strictEqual( + (yield* service.diff({ ...reference, cursor: "page-2" })).patch, + "old:page-2", + ); + + revision = "new"; + yield* service.invalidate({ reference, scope: "detail" }); + assert.strictEqual((yield* service.diff(reference)).patch, "old:first"); + assert.strictEqual( + (yield* service.diff({ ...reference, cursor: "page-2" })).patch, + "old:page-2", + ); + assert.deepStrictEqual(calls, [undefined, "page-2"]); + + failDetail = true; + yield* Effect.flip(service.detail(reference)); + assert.strictEqual((yield* service.diff(reference)).patch, "old:first"); + assert.strictEqual( + (yield* service.diff({ ...reference, cursor: "page-2" })).patch, + "old:page-2", + ); + assert.deepStrictEqual(calls, [undefined, "page-2"]); + + yield* service.invalidate({ reference }); + assert.strictEqual((yield* service.diff(reference)).patch, "new:first"); + assert.strictEqual( + (yield* service.diff({ ...reference, cursor: "page-2" })).patch, + "new:page-2", + ); + assert.deepStrictEqual(calls, [undefined, "page-2", undefined, "page-2"]); + }), +); + it.effect("does not ask the host again for a linked summary it already holds", () => Effect.gen(function* () { let calls = 0; diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 241a9e8b1836..271b139d300c 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -2263,15 +2263,19 @@ export const make = Effect.gen(function* () { // epoch strands every entry made under the old one — no enumerating a cache whose keys // (cursors, commits) nothing holds a list of. The counter is shared and monotonic so a // scope re-entering `refEpochs` after eviction can never mint a key an old entry still has. + // Detail and diff ride separate epochs: a poll only needs title/check freshness, so it + // strands the cheap detail hold while the mounted diff keeps its stale-while-revalidate + // path. Mutations and the manual refresh strand both. let epochCounter = 0; let listingsEpoch = 0; let turnRefreshEpoch = 0; const refEpochs = new Map(); + const diffEpochs = new Map(); const REF_EPOCH_CAPACITY = 2_048; const refScope = (ref: PullRequestRef) => `${ref.projectId} ${ref.host?.toLowerCase() ?? ""} ${ref.repository.toLowerCase()} ${ref.number}`; - const refEpoch = (ref: PullRequestRef) => - Math.max(turnRefreshEpoch, refEpochs.get(refScope(ref)) ?? 0); + const refEpoch = (ref: PullRequestRef) => mapEpoch(refEpochs, ref); + const diffEpoch = (ref: PullRequestRef) => mapEpoch(diffEpochs, ref); // Keys carry the reference back out of the cache loader, so the slot layout is shared with // `refOfCacheKey` rather than read positionally at every loader. const refCacheKey = (ref: PullRequestRef) => @@ -2312,13 +2316,29 @@ export const make = Effect.gen(function* () { if (oldest !== undefined) recentStats.delete(oldest); } }; - const bumpRefEpoch = (ref: PullRequestRef) => { + const bumpMapEpoch = (epochs: Map, ref: PullRequestRef) => { const scope = refScope(ref); - if (!refEpochs.has(scope) && refEpochs.size >= REF_EPOCH_CAPACITY) { - const oldest = refEpochs.keys().next().value; - if (oldest !== undefined) refEpochs.delete(oldest); + if (!epochs.has(scope) && epochs.size >= REF_EPOCH_CAPACITY) { + const oldest = epochs.keys().next().value; + if (oldest !== undefined) epochs.delete(oldest); } - refEpochs.set(scope, ++epochCounter); + const epoch = ++epochCounter; + epochs.delete(scope); + epochs.set(scope, epoch); + return epoch; + }; + // Reads reserve an epoch too: returning a default after eviction would revive held keys. + const mapEpoch = (epochs: Map, ref: PullRequestRef) => { + const scope = refScope(ref); + const epoch = epochs.get(scope) ?? bumpMapEpoch(epochs, ref); + epochs.delete(scope); + epochs.set(scope, epoch); + return Math.max(turnRefreshEpoch, epoch); + }; + const bumpDetailEpoch = (ref: PullRequestRef) => bumpMapEpoch(refEpochs, ref); + const bumpRefEpoch = (ref: PullRequestRef) => { + bumpMapEpoch(refEpochs, ref); + bumpMapEpoch(diffEpochs, ref); }; /** The positional filter slot of a cache key, back as the record `listUncached` takes. */ @@ -2597,20 +2617,29 @@ export const make = Effect.gen(function* () { }, }, ); + const lastGoodDiffRevision = makeLastGoodRead(DIFF_CACHE_CAPACITY); const diff: PullRequestService["Service"]["diff"] = (input) => { + const epoch = diffEpoch(input); + const revisionKey = JSON.stringify([epoch, refScope(input)]); + const observedRevision = lastGoodSummary.peek(refCacheKey(input))?.updatedAt; + // Detail invalidation must not erase the revision identifying held diff pages while + // the fresh metadata is pending or fails. Full invalidation changes this key's epoch. + const revision = observedRevision ?? lastGoodDiffRevision.peek(revisionKey) ?? null; + const rememberRevision = + input.commit === undefined && observedRevision !== undefined + ? lastGoodDiffRevision.record(revisionKey, observedRevision) + : Effect.void; const key = JSON.stringify([ - refEpoch(input), + epoch, input.projectId, input.host?.toLowerCase() ?? null, input.repository.toLowerCase(), input.number, input.cursor ?? null, input.commit ?? null, - input.commit === undefined - ? (lastGoodSummary.peek(refCacheKey(input))?.updatedAt ?? null) - : null, + input.commit === undefined ? revision : null, ]); - return staleDiff(key, Cache.get(diffCache, key)); + return rememberRevision.pipe(Effect.andThen(staleDiff(key, Cache.get(diffCache, key)))); }; const listStatsCache = yield* Cache.makeWith( @@ -2673,7 +2702,13 @@ export const make = Effect.gen(function* () { const invalidate: PullRequestService["Service"]["invalidate"] = (input) => { const reference = input.reference; if (reference !== undefined) { - return readCache.invalidate.pipe(Effect.andThen(Effect.sync(() => bumpRefEpoch(reference)))); + return readCache.invalidate.pipe( + Effect.andThen( + Effect.sync(() => + input.scope === "detail" ? bumpDetailEpoch(reference) : bumpRefEpoch(reference), + ), + ), + ); } return Effect.sync(() => { listingsEpoch = ++epochCounter; diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 60188e546d4d..262c9b5572fc 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -73,7 +73,7 @@ import { buildPhysicalToLogicalProjectKeyMap } from "~/sidebarProjectGrouping"; import { useProjects, useServerConfigs } from "~/state/entities"; import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; import { useEnvironmentQuery } from "~/state/query"; -import { useLiveRefresh } from "~/hooks/useLiveRefresh"; +import { usePullRequestRefresh } from "./usePullRequestRefresh"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { usePullRequestTurnRefresh, useSharedPullRequestSummary } from "~/state/pullRequests"; import { useAtomCommand } from "~/state/use-atom-command"; @@ -144,7 +144,6 @@ import { resolveBaseFreshness, resolvePullRequestMergeMethod, type PullRequestFinding, - shouldRefreshPullRequestActivity, stripPullRequestHandoffReferences, writePullRequestDetailSnapshot, } from "./pullRequestDetail.logic"; @@ -775,52 +774,17 @@ export function PullRequestDetailPanel({ activityQuery.refresh(); nativeStackQuery.refresh(); }, [activityQuery.refresh, detailQuery.refresh, nativeStackQuery.refresh]); - const [refreshToken, setRefreshToken] = useState(0); + const { refreshToken, isInvalidating, refreshFromHost } = usePullRequestRefresh({ + environmentId, + reference, + scopeKey: tabScopeKey, + detail: coreDetail, + refreshMetadata: detailQuery.refresh, + refreshActivity: activityQuery.refresh, + refreshDetail, + forcedRefreshToken, + }); const codeRefreshToken = refreshToken + (turnRefresh ?? 0); - const activityRevision = useRef<{ readonly key: string; readonly updatedAt: string } | null>( - null, - ); - useEffect(() => { - if (!coreDetail) return; - const next = { key: tabScopeKey, updatedAt: coreDetail.updatedAt }; - if (shouldRefreshPullRequestActivity(activityRevision.current, next)) { - activityQuery.refresh(); - setRefreshToken((token) => token + 1); - } - activityRevision.current = next; - }, [activityQuery.refresh, coreDetail, tabScopeKey]); - // Reuse activity and diff until core detail reports a changed revision. Keyed by - // the pull request rather than by the panel, because this one panel shows a different pull - // request every time it is opened. - useLiveRefresh( - () => { - detailQuery.refresh(); - }, - { key: `pull-request:${environmentId}:${pullRequestKey}` }, - ); - // The button, on the other hand, goes around the server's cache rather than through it: it is - // the answer for a reader who can see that what they are looking at is behind. The - // invalidation goes first so the re-reads miss that cache; if it fails, the reads still run - // and at worst answer from it. - const invalidate = useAtomCommand(pullRequestEnvironment.invalidate, { reportFailure: false }); - const [isInvalidating, setIsInvalidating] = useState(false); - const refreshFromHost = useCallback(async () => { - setIsInvalidating(true); - try { - await invalidate({ environmentId, input: { reference } }); - refreshDetail(); - setRefreshToken((token) => token + 1); - } finally { - setIsInvalidating(false); - } - }, [environmentId, invalidate, reference, refreshDetail]); - // A refresh asked for by the page: the detail, and through the token below, the diff with it. - const appliedForcedToken = useRef(forcedRefreshToken); - useEffect(() => { - if (appliedForcedToken.current === forcedRefreshToken) return; - appliedForcedToken.current = forcedRefreshToken; - void refreshFromHost(); - }, [forcedRefreshToken, refreshFromHost]); const runAction = useAtomCommand(pullRequestEnvironment.runAction, { reportFailure: false }); const postComment = useAtomCommand(pullRequestEnvironment.comment, { reportFailure: false }); // Which action is in flight, not merely that one is: every control here is disabled while any diff --git a/apps/web/src/components/pullRequest/usePullRequestRefresh.test.tsx b/apps/web/src/components/pullRequest/usePullRequestRefresh.test.tsx new file mode 100644 index 000000000000..75d35b4a3c40 --- /dev/null +++ b/apps/web/src/components/pullRequest/usePullRequestRefresh.test.tsx @@ -0,0 +1,291 @@ +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { Cause } from "effect"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { act, StrictMode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const { invalidate, notify } = vi.hoisted(() => ({ invalidate: vi.fn(), notify: vi.fn() })); +vi.mock("~/state/pullRequests", () => ({ pullRequestEnvironment: { invalidate: {} } })); +vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => invalidate })); +vi.mock("../ui/toast", () => ({ toastManager: { add: notify } })); + +import { LIVE_REFRESH_INTERVAL_MS } from "~/hooks/useLiveRefresh"; +import { usePullRequestRefresh } from "./usePullRequestRefresh"; + +type Props = Parameters[0]; +const refreshMetadata = vi.fn(); +const refreshActivity = vi.fn(); +const refreshDetail = vi.fn(); +let renderer: ReactTestRenderer | null; +let props: Props; +let testNumber = 0; + +function PanelReads(input: Props) { + const { refreshToken, isInvalidating, refreshFromHost } = usePullRequestRefresh(input); + return ( + <> + {refreshToken} + + + ); +} + +async function render(changes: Partial = {}) { + props = { ...props, ...changes }; + await act(async () => { + const panel = ( + + + + ); + if (renderer) renderer.update(panel); + else renderer = create(panel); + }); +} + +function diffRefreshes() { + return renderer!.root.findByType("output").children.join(""); +} + +function invalidation() { + let resolve!: (result: AtomCommandResult) => void; + const promise = new Promise>((resolvePromise) => { + resolve = resolvePromise; + }); + invalidate.mockReturnValueOnce(promise); + return { + succeed: () => act(async () => resolve(AsyncResult.success(undefined))), + fail: () => act(async () => resolve(AsyncResult.failure(Cause.fail(new Error("offline"))))), + }; +} + +async function poll() { + await act(async () => vi.advanceTimersByTimeAsync(LIVE_REFRESH_INTERVAL_MS)); +} + +beforeEach(() => { + renderer = null; + vi.useFakeTimers(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", new EventTarget()); + vi.stubGlobal("document", Object.assign(new EventTarget(), { visibilityState: "visible" })); + invalidate.mockReset().mockResolvedValue(AsyncResult.success(undefined)); + notify.mockReset(); + refreshMetadata.mockReset(); + refreshActivity.mockReset(); + refreshDetail.mockReset(); + props = { + environmentId: EnvironmentId.make("environment"), + reference: { + projectId: ProjectId.make("project"), + host: "github.com", + repository: "acme/web", + number: 7, + }, + scopeKey: `environment:project:github.com:acme/web#7:test-${++testNumber}`, + detail: { updatedAt: "2026-09-10T10:00:00Z" }, + refreshMetadata, + refreshActivity, + refreshDetail, + forcedRefreshToken: 0, + }; +}); + +afterEach(async () => { + await act(async () => renderer?.unmount()); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("mounted pull request refresh sequencing", () => { + it("awaits detail-only poll invalidation and preserves activity and diff for unchanged metadata", async () => { + await render(); + expect(invalidate).not.toHaveBeenCalled(); + const pending = invalidation(); + await poll(); + expect(invalidate).toHaveBeenCalledExactlyOnceWith({ + environmentId: props.environmentId, + input: { reference: props.reference, scope: "detail" }, + }); + expect(refreshMetadata).not.toHaveBeenCalled(); + expect(refreshActivity).not.toHaveBeenCalled(); + expect(diffRefreshes()).toBe("0"); + await pending.succeed(); + expect(refreshMetadata).toHaveBeenCalledOnce(); + await render({ detail: { ...props.detail! } }); + expect(refreshActivity).not.toHaveBeenCalled(); + expect(diffRefreshes()).toBe("0"); + }); + + it("awaits full invalidation of a polled revision before refreshing activity and the diff", async () => { + await render(); + await poll(); + expect(refreshMetadata).toHaveBeenCalledOnce(); + const pending = invalidation(); + await render({ detail: { updatedAt: "2026-09-10T10:01:00Z" } }); + expect(invalidate).toHaveBeenLastCalledWith({ + environmentId: props.environmentId, + input: { reference: props.reference }, + }); + expect(refreshActivity).not.toHaveBeenCalled(); + expect(diffRefreshes()).toBe("0"); + await pending.succeed(); + expect(refreshActivity).toHaveBeenCalledOnce(); + expect(diffRefreshes()).toBe("1"); + }); + + it("ignores a completed invalidation superseded by a newer revision", async () => { + await render(); + const earlier = invalidation(); + await render({ detail: { updatedAt: "2026-09-10T10:01:00Z" } }); + const latest = invalidation(); + await render({ detail: { updatedAt: "2026-09-10T10:02:00Z" } }); + await earlier.succeed(); + expect(refreshActivity).not.toHaveBeenCalled(); + expect(diffRefreshes()).toBe("0"); + await latest.succeed(); + expect(refreshActivity).toHaveBeenCalledOnce(); + expect(diffRefreshes()).toBe("1"); + }); + + it("ignores a revision invalidation after selecting another pull request", async () => { + await render(); + const pending = invalidation(); + await render({ detail: { updatedAt: "2026-09-10T10:01:00Z" } }); + await render({ + reference: { ...props.reference, number: 8 }, + scopeKey: `${props.scopeKey}:other`, + }); + await pending.succeed(); + expect(refreshActivity).not.toHaveBeenCalled(); + expect(diffRefreshes()).toBe("0"); + }); + + it("does not refresh activity after the panel unmounts during invalidation", async () => { + await render(); + const pending = invalidation(); + await render({ detail: { updatedAt: "2026-09-10T10:01:00Z" } }); + await act(async () => renderer!.unmount()); + renderer = null; + await pending.succeed(); + expect(refreshActivity).not.toHaveBeenCalled(); + }); + + it("keeps activity and diff on failed revision invalidation and retries on the next metadata result", async () => { + await render(); + const pending = invalidation(); + await render({ detail: { updatedAt: "2026-09-10T10:01:00Z" } }); + await pending.fail(); + expect(refreshActivity).not.toHaveBeenCalled(); + expect(diffRefreshes()).toBe("0"); + expect(notify).toHaveBeenCalledWith(expect.objectContaining({ type: "error" })); + await render({ detail: { ...props.detail! } }); + expect(invalidate).toHaveBeenCalledTimes(2); + expect(refreshActivity).toHaveBeenCalledOnce(); + expect(diffRefreshes()).toBe("1"); + }); + + it("does not reread held metadata when poll invalidation fails", async () => { + await render(); + const pending = invalidation(); + await poll(); + await pending.fail(); + expect(refreshMetadata).not.toHaveBeenCalled(); + expect(refreshActivity).not.toHaveBeenCalled(); + expect(diffRefreshes()).toBe("0"); + }); + + it("reports failed manual invalidation without refreshing and allows a successful retry", async () => { + await render(); + const failed = invalidation(); + await act(async () => { + void renderer!.root.findByType("button").props.onClick(); + }); + expect(renderer!.root.findByType("button").props.disabled).toBe(true); + expect(refreshDetail).not.toHaveBeenCalled(); + await failed.fail(); + expect(refreshDetail).not.toHaveBeenCalled(); + expect(diffRefreshes()).toBe("0"); + expect(notify).toHaveBeenCalledExactlyOnceWith({ + type: "error", + title: "The pull request could not be refreshed", + description: "offline", + }); + expect(renderer!.root.findByType("button").props.disabled).toBe(false); + + const retry = invalidation(); + await act(async () => { + void renderer!.root.findByType("button").props.onClick(); + }); + expect(renderer!.root.findByType("button").props.disabled).toBe(true); + expect(refreshDetail).not.toHaveBeenCalled(); + expect(diffRefreshes()).toBe("0"); + await retry.succeed(); + expect(invalidate).toHaveBeenCalledTimes(2); + expect(refreshDetail).toHaveBeenCalledOnce(); + expect(diffRefreshes()).toBe("1"); + expect(notify).toHaveBeenCalledOnce(); + expect(renderer!.root.findByType("button").props.disabled).toBe(false); + }); + + it.each(["success", "failure"])( + "ignores an older manual %s while a forced refresh is pending", + async (outcome) => { + await render(); + const older = invalidation(); + await act(async () => { + void renderer!.root.findByType("button").props.onClick(); + }); + const newer = invalidation(); + await render({ forcedRefreshToken: 1 }); + await (outcome === "success" ? older.succeed() : older.fail()); + expect(renderer!.root.findByType("button").props.disabled).toBe(true); + expect(refreshDetail).not.toHaveBeenCalled(); + expect(notify).not.toHaveBeenCalled(); + expect(diffRefreshes()).toBe("0"); + await newer.succeed(); + expect(renderer!.root.findByType("button").props.disabled).toBe(false); + expect(refreshDetail).toHaveBeenCalledOnce(); + expect(diffRefreshes()).toBe("1"); + }, + ); + + it.each(["scope", "unmount"])("ignores a manual refresh after %s changes", async (change) => { + await render(); + const pending = invalidation(); + await act(async () => { + void renderer!.root.findByType("button").props.onClick(); + }); + if (change === "scope") { + await render({ + scopeKey: `${props.scopeKey}:other`, + reference: { ...props.reference, number: 8 }, + }); + expect(renderer!.root.findByType("button").props.disabled).toBe(false); + } else { + await act(async () => renderer!.unmount()); + renderer = null; + } + await pending.succeed(); + expect(refreshDetail).not.toHaveBeenCalled(); + expect(notify).not.toHaveBeenCalled(); + if (renderer) expect(diffRefreshes()).toBe("0"); + }); + + it("awaits full invalidation before a page refresh", async () => { + await render(); + const pending = invalidation(); + await render({ forcedRefreshToken: 1 }); + expect(refreshDetail).not.toHaveBeenCalled(); + expect(diffRefreshes()).toBe("0"); + expect(renderer!.root.findByType("button").props.disabled).toBe(true); + await pending.succeed(); + expect(refreshDetail).toHaveBeenCalledOnce(); + expect(diffRefreshes()).toBe("1"); + expect(renderer!.root.findByType("button").props.disabled).toBe(false); + }); +}); diff --git a/apps/web/src/components/pullRequest/usePullRequestRefresh.ts b/apps/web/src/components/pullRequest/usePullRequestRefresh.ts new file mode 100644 index 000000000000..7fa0862f8610 --- /dev/null +++ b/apps/web/src/components/pullRequest/usePullRequestRefresh.ts @@ -0,0 +1,125 @@ +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentId, PullRequestDetail, PullRequestRef } from "@t3tools/contracts"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useLiveRefresh } from "~/hooks/useLiveRefresh"; +import { pullRequestEnvironment } from "~/state/pullRequests"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { toastManager } from "../ui/toast"; +import { readableFailure, shouldRefreshPullRequestActivity } from "./pullRequestDetail.logic"; + +/** Coordinates host invalidation with the mounted panel's metadata, activity and diff reads. */ +export function usePullRequestRefresh({ + environmentId, + reference, + scopeKey, + detail, + refreshMetadata, + refreshActivity, + refreshDetail, + forcedRefreshToken, +}: { + environmentId: EnvironmentId; + reference: PullRequestRef; + scopeKey: string; + detail: Pick | null; + refreshMetadata: () => void; + refreshActivity: () => void; + refreshDetail: () => void; + forcedRefreshToken: number; +}) { + const [refreshToken, setRefreshToken] = useState(0); + const invalidate = useAtomCommand(pullRequestEnvironment.invalidate, { reportFailure: false }); + const activityRevision = useRef<{ readonly key: string; readonly updatedAt: string } | null>( + null, + ); + useEffect( + () => () => { + activityRevision.current = null; + }, + [], + ); + useEffect(() => { + if (!detail) { + activityRevision.current = null; + return; + } + const next = { key: scopeKey, updatedAt: detail.updatedAt }; + if ( + activityRevision.current?.key === next.key && + activityRevision.current.updatedAt === next.updatedAt + ) + return; + const previous = activityRevision.current; + const changed = shouldRefreshPullRequestActivity(previous, next); + activityRevision.current = next; + if (!changed) return; + // A changed revision must miss the held diff before the Code tab reads its first page. + // Later revisions or another PR supersede this refresh while invalidation is in flight. + void invalidate({ environmentId, input: { reference } }).then((result) => { + if (activityRevision.current !== next) return; + if (result._tag === "Failure") { + activityRevision.current = previous; + toastManager.add({ + type: "error", + title: "The pull request could not be refreshed", + description: readableFailure(squashAtomCommandFailure(result), "Try refreshing again."), + }); + return; + } + refreshActivity(); + setRefreshToken((token) => token + 1); + }); + }, [refreshActivity, detail, environmentId, invalidate, reference, scopeKey]); + // Poll fresh metadata without invalidating cached diff pages. A changed detail revision + // refreshes activity and the Code tab above; unchanged polls preserve loaded slices. + const refreshDetailFromHost = useCallback(async () => { + const result = await invalidate({ environmentId, input: { reference, scope: "detail" } }); + if (result._tag === "Success") refreshMetadata(); + }, [refreshMetadata, environmentId, invalidate, reference]); + useLiveRefresh(() => void refreshDetailFromHost(), { + key: `pull-request:${scopeKey}`, + }); + const refreshScope = useMemo(() => ({ key: scopeKey }), [scopeKey]); + const activeRefreshScope = useRef(null); + const refreshGeneration = useRef(0); + const [pendingScope, setPendingScope] = useState(null); + useEffect(() => { + activeRefreshScope.current = refreshScope; + return () => { + activeRefreshScope.current = null; + refreshGeneration.current += 1; + }; + }, [refreshScope]); + const isInvalidating = pendingScope === refreshScope; + + const refreshFromHost = useCallback(async () => { + const generation = ++refreshGeneration.current; + setPendingScope(refreshScope); + try { + const result = await invalidate({ environmentId, input: { reference } }); + if (activeRefreshScope.current !== refreshScope || generation !== refreshGeneration.current) + return; + if (result._tag === "Failure") { + toastManager.add({ + type: "error", + title: "The pull request could not be refreshed", + description: readableFailure(squashAtomCommandFailure(result), "Try refreshing again."), + }); + return; + } + refreshDetail(); + setRefreshToken((token) => token + 1); + } finally { + if (activeRefreshScope.current === refreshScope && generation === refreshGeneration.current) + setPendingScope(null); + } + }, [environmentId, invalidate, reference, refreshDetail, refreshScope]); + // A refresh asked for by the page: the detail, and through the token below, the diff with it. + const appliedForcedToken = useRef(forcedRefreshToken); + useEffect(() => { + if (appliedForcedToken.current === forcedRefreshToken) return; + appliedForcedToken.current = forcedRefreshToken; + void refreshFromHost(); + }, [forcedRefreshToken, refreshFromHost]); + return { refreshToken, isInvalidating, refreshFromHost }; +} diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 44658bbc6c3e..2a1762c5c344 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -751,9 +751,12 @@ export type PullRequestListStatsResult = typeof PullRequestListStatsResult.Type; * forgets that one change request's detail and diff; without one it forgets the listings. * A separate request rather than a flag on the reads, so an explicit "refresh" one person * presses is the only thing that spends host requests — every ordinary read shares. + * The detail scope forgets only the detail (and its summary/activity siblings) while leaving + * the diff's stale-while-revalidate hold alone, for polls that only need title/check freshness. */ export const PullRequestInvalidateInput = Schema.Struct({ reference: Schema.optional(PullRequestRef), + scope: Schema.optional(Schema.Literals(["detail", "all"])), }); export type PullRequestInvalidateInput = typeof PullRequestInvalidateInput.Type;