diff --git a/apps/loopover-miner-ui/src/ledgers.test.tsx b/apps/loopover-miner-ui/src/ledgers.test.tsx index a61bf552f4..652a8216f2 100644 --- a/apps/loopover-miner-ui/src/ledgers.test.tsx +++ b/apps/loopover-miner-ui/src/ledgers.test.tsx @@ -367,6 +367,50 @@ describe("LedgersPage (#4855)", () => { await waitFor(() => expect(screen.getByRole("alert").textContent).toContain("connection refused")); expect(screen.getByText(/No ledger activity yet/i)).toBeTruthy(); }); + + it("does not let a stale in-flight poll response revert a just-applied pause action (#7791)", async () => { + // The exact race from the bug report: a poll GET is already in flight when the operator clicks Pause, the + // action POST resolves first, and only THEN does the stale pre-pause poll resolve. The first (mount) poll + // resolves "not paused" so the Pause button renders; the SECOND poll tick is a deferred left in flight + // while we click and let the action land, then resolved by hand with the stale pre-pause state. + let resolveStalePoll: (value: GovernorPauseStateResult) => void = () => undefined; + let pollCall = 0; + const loadGovernorPauseState = vi.fn((): Promise => { + pollCall += 1; + if (pollCall === 1) return Promise.resolve({ ok: true, pauseState: defaultGovernorPauseState() }); + return new Promise((resolve) => { + resolveStalePoll = resolve; + }); + }); + const pauseGovernorAction = vi.fn(async (): Promise => ({ + ok: true, + pauseState: { paused: true, reason: null, pausedAt: "2026-07-13T12:30:00.000Z" }, + })); + render( + , + ); + + // First poll resolved "not paused" -> the Pause button is shown. + await waitFor(() => expect(screen.getByRole("button", { name: "Pause governor" })).toBeTruthy()); + // Let the next poll tick fire and leave its GET in flight (deferred, unresolved). + await waitFor(() => expect(pollCall).toBeGreaterThanOrEqual(2)); + + // Operator clicks Pause; the action POST resolves first -> UI reflects "paused" (Resume button shown). + fireEvent.click(screen.getByRole("button", { name: "Pause governor" })); + await waitFor(() => expect(screen.getByRole("button", { name: "Resume governor" })).toBeTruthy()); + + // Now the STALE second poll (started before the action) finally resolves with the pre-pause "not paused" + // state. Without the ordering guard this clobbers the action's result; with it, the UI stays paused. + resolveStalePoll({ ok: true, pauseState: defaultGovernorPauseState() }); + await Promise.resolve(); + await waitFor(() => expect(screen.getByRole("button", { name: "Resume governor" })).toBeTruthy()); + expect(screen.queryByRole("button", { name: "Pause governor" })).toBeNull(); + }); }); describe("live refresh (#7082)", () => { diff --git a/apps/loopover-miner-ui/src/routes/ledgers.tsx b/apps/loopover-miner-ui/src/routes/ledgers.tsx index 9c1858b1fc..883b2468b6 100644 --- a/apps/loopover-miner-ui/src/routes/ledgers.tsx +++ b/apps/loopover-miner-ui/src/routes/ledgers.tsx @@ -429,6 +429,14 @@ export function LedgersPage({ const [pauseState, setPauseState] = useState(null); const [lastPolledPauseState, setLastPolledPauseState] = useState(null); const [actionPending, setActionPending] = useState(false); + // A pause/resume POST is an independent request that doesn't share the poll GET's single-flight guard, so a + // poll that was already in flight when the operator acted can resolve AFTER the action and clobber its fresh + // result with a stale pre-action value (#7791). `skipNextPollSync` marks that the very next poll-sync after an + // action lands must be ignored: that poll may have started before the action, so its result is not newer. + // Later ticks (started after the action) sync normally, mirroring the generation/cancellation discipline + // usePolledFetch/useStreamingText use internally. It's state (not a ref) so the render-phase sync below can + // read it without touching a ref during render. + const [skipNextPollSync, setSkipNextPollSync] = useState(false); // Join the app's shared live-refresh cadence so newly-recorded claims/events appear without a manual reload, // matching the Overview page's claims card that reads the same data source (#7082). @@ -440,12 +448,19 @@ export function LedgersPage({ const { result: polledPauseState } = usePolledFetch(loadGovernorPauseState, pollIntervalMs); if (polledPauseState !== lastPolledPauseState) { setLastPolledPauseState(polledPauseState); - setPauseState(polledPauseState); + // Consume this poll result (so lastPolledPauseState advances and we don't keep skipping), but don't let a + // stale in-flight poll overwrite an action's just-applied result (#7791). + if (skipNextPollSync) { + setSkipNextPollSync(false); + } else { + setPauseState(polledPauseState); + } } const runGovernorAction = (action: () => Promise) => { setActionPending(true); void action().then((next) => { + setSkipNextPollSync(true); setPauseState(next); setActionPending(false); });