Skip to content

Commit 57763eb

Browse files
committed
fix(miner-ui): stop a stale governor poll from reverting a just-applied pause/resume
LedgersPage syncs the governor pause-state from two independent sources with no ordering guard: a usePolledFetch GET synced during render, and the operator's pause/resume POST written directly on resolve. The POST doesn't share the poll's single-flight guard, so a poll GET already in flight when the operator clicks can resolve after the action's POST and clobber the fresh result with a stale pre-action value -- the UI reverts to "Not paused" (or "paused") until the next tick self-corrects. Mark the next poll-sync after an action lands as to-skip (that poll may predate the action, so its result is not newer); later ticks sync normally, mirroring the generation/cancellation discipline usePolledFetch and useStreamingText already use. The flag is state, not a ref, so the render-phase sync reads it without accessing a ref during render.
1 parent 80eb9f3 commit 57763eb

2 files changed

Lines changed: 60 additions & 1 deletion

File tree

apps/loopover-miner-ui/src/ledgers.test.tsx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,50 @@ describe("LedgersPage (#4855)", () => {
367367
await waitFor(() => expect(screen.getByRole("alert").textContent).toContain("connection refused"));
368368
expect(screen.getByText(/No ledger activity yet/i)).toBeTruthy();
369369
});
370+
371+
it("does not let a stale in-flight poll response revert a just-applied pause action (#7791)", async () => {
372+
// The exact race from the bug report: a poll GET is already in flight when the operator clicks Pause, the
373+
// action POST resolves first, and only THEN does the stale pre-pause poll resolve. The first (mount) poll
374+
// resolves "not paused" so the Pause button renders; the SECOND poll tick is a deferred left in flight
375+
// while we click and let the action land, then resolved by hand with the stale pre-pause state.
376+
let resolveStalePoll: (value: GovernorPauseStateResult) => void = () => undefined;
377+
let pollCall = 0;
378+
const loadGovernorPauseState = vi.fn((): Promise<GovernorPauseStateResult> => {
379+
pollCall += 1;
380+
if (pollCall === 1) return Promise.resolve({ ok: true, pauseState: defaultGovernorPauseState() });
381+
return new Promise<GovernorPauseStateResult>((resolve) => {
382+
resolveStalePoll = resolve;
383+
});
384+
});
385+
const pauseGovernorAction = vi.fn(async (): Promise<GovernorPauseStateResult> => ({
386+
ok: true,
387+
pauseState: { paused: true, reason: null, pausedAt: "2026-07-13T12:30:00.000Z" },
388+
}));
389+
render(
390+
<LedgersPage
391+
loadLedgers={loadLedgersEmpty}
392+
loadGovernorPauseState={loadGovernorPauseState}
393+
pauseGovernorAction={pauseGovernorAction}
394+
pollIntervalMs={20}
395+
/>,
396+
);
397+
398+
// First poll resolved "not paused" -> the Pause button is shown.
399+
await waitFor(() => expect(screen.getByRole("button", { name: "Pause governor" })).toBeTruthy());
400+
// Let the next poll tick fire and leave its GET in flight (deferred, unresolved).
401+
await waitFor(() => expect(pollCall).toBeGreaterThanOrEqual(2));
402+
403+
// Operator clicks Pause; the action POST resolves first -> UI reflects "paused" (Resume button shown).
404+
fireEvent.click(screen.getByRole("button", { name: "Pause governor" }));
405+
await waitFor(() => expect(screen.getByRole("button", { name: "Resume governor" })).toBeTruthy());
406+
407+
// Now the STALE second poll (started before the action) finally resolves with the pre-pause "not paused"
408+
// state. Without the ordering guard this clobbers the action's result; with it, the UI stays paused.
409+
resolveStalePoll({ ok: true, pauseState: defaultGovernorPauseState() });
410+
await Promise.resolve();
411+
await waitFor(() => expect(screen.getByRole("button", { name: "Resume governor" })).toBeTruthy());
412+
expect(screen.queryByRole("button", { name: "Pause governor" })).toBeNull();
413+
});
370414
});
371415

372416
describe("live refresh (#7082)", () => {

apps/loopover-miner-ui/src/routes/ledgers.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,14 @@ export function LedgersPage({
429429
const [pauseState, setPauseState] = useState<GovernorPauseStateResult | null>(null);
430430
const [lastPolledPauseState, setLastPolledPauseState] = useState<GovernorPauseStateResult | null>(null);
431431
const [actionPending, setActionPending] = useState(false);
432+
// A pause/resume POST is an independent request that doesn't share the poll GET's single-flight guard, so a
433+
// poll that was already in flight when the operator acted can resolve AFTER the action and clobber its fresh
434+
// result with a stale pre-action value (#7791). `skipNextPollSync` marks that the very next poll-sync after an
435+
// action lands must be ignored: that poll may have started before the action, so its result is not newer.
436+
// Later ticks (started after the action) sync normally, mirroring the generation/cancellation discipline
437+
// usePolledFetch/useStreamingText use internally. It's state (not a ref) so the render-phase sync below can
438+
// read it without touching a ref during render.
439+
const [skipNextPollSync, setSkipNextPollSync] = useState(false);
432440

433441
// Join the app's shared live-refresh cadence so newly-recorded claims/events appear without a manual reload,
434442
// matching the Overview page's claims card that reads the same data source (#7082).
@@ -440,12 +448,19 @@ export function LedgersPage({
440448
const { result: polledPauseState } = usePolledFetch(loadGovernorPauseState, pollIntervalMs);
441449
if (polledPauseState !== lastPolledPauseState) {
442450
setLastPolledPauseState(polledPauseState);
443-
setPauseState(polledPauseState);
451+
// Consume this poll result (so lastPolledPauseState advances and we don't keep skipping), but don't let a
452+
// stale in-flight poll overwrite an action's just-applied result (#7791).
453+
if (skipNextPollSync) {
454+
setSkipNextPollSync(false);
455+
} else {
456+
setPauseState(polledPauseState);
457+
}
444458
}
445459

446460
const runGovernorAction = (action: () => Promise<GovernorPauseStateResult>) => {
447461
setActionPending(true);
448462
void action().then((next) => {
463+
setSkipNextPollSync(true);
449464
setPauseState(next);
450465
setActionPending(false);
451466
});

0 commit comments

Comments
 (0)