diff --git a/src/client/hud/layers/WinModal.ts b/src/client/hud/layers/WinModal.ts index d20444e825..8cbf6f51c5 100644 --- a/src/client/hud/layers/WinModal.ts +++ b/src/client/hud/layers/WinModal.ts @@ -79,6 +79,9 @@ export class WinModal extends LitElement implements Controller { // Polling starts once, at the end of the game. private xpPolling = false; private xpAbort: AbortController | null = null; + // A detach cut the end-of-game poll short; re-attaching restarts it (the + // Win update that started it is delivered only once). + private xpPollAborted = false; // Whose XP the section shows: the Legend ceremony plays once per account. private xpPublicId: string | null = null; // The Legend ceremony, fetched only for a player who could become a Legend @@ -504,6 +507,14 @@ export class WinModal extends LitElement implements Controller { this.requestUpdate(); } + connectedCallback(): void { + super.connectedCallback(); + if (this.xpPollAborted) { + this.xpPollAborted = false; + void this.updateXp(true); + } + } + disconnectedCallback(): void { this.xpAbort?.abort(); super.disconnectedCallback(); @@ -551,10 +562,17 @@ export class WinModal extends LitElement implements Controller { } this.xpPolling = true; this.xpView = { kind: "calculating" }; - this.xpAbort = new AbortController(); - const result = await pollGameXp(game.gameID(), { - signal: this.xpAbort.signal, - }); + const abort = new AbortController(); + this.xpAbort = abort; + const result = await pollGameXp(game.gameID(), { signal: abort.signal }); + // Detached mid-poll: leave the section be and poll again on re-attach + // (right away if that happened while this poll was winding down). + if (abort.signal.aborted) { + this.xpPolling = false; + if (this.isConnected) void this.updateXp(true); + else this.xpPollAborted = true; + return; + } if (result?.eligible && reachedLegendThisGame(result)) { this.preloadLegendCeremony(); } diff --git a/tests/client/graphics/layers/WinModalXp.test.ts b/tests/client/graphics/layers/WinModalXp.test.ts index f3084eb610..ea88450a7d 100644 --- a/tests/client/graphics/layers/WinModalXp.test.ts +++ b/tests/client/graphics/layers/WinModalXp.test.ts @@ -1120,6 +1120,41 @@ describe("WinModal XP section", () => { expect(panel()!.getAttribute("aria-busy")).toBe("false"); }); + it("polls again when the modal is detached mid-poll and re-attached", async () => { + const firstFetch = stubXpEndpoint([notFound]); + await mount(makeGame({ ended: true })); + expect(xpState()).toBe("calculating"); + const view = () => + (modal as unknown as { xpView: { kind: string } }).xpView.kind; + + // Detaching aborts the poll; that is not a verdict on the XP. + modal.remove(); + await settle(3_000); + expect(firstFetch).toHaveBeenCalledTimes(1); + expect(view()).toBe("calculating"); + + // The Win update came once and is spent: re-attaching alone polls afresh. + const fetchMock = stubXpEndpoint([() => json(eligible())]); + document.body.appendChild(modal); + await settle(); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(xpState()).toBe("result"); + }); + + it("polls again when re-attached before the aborted poll has wound down", async () => { + stubXpEndpoint([notFound]); + await mount(makeGame({ ended: true })); + expect(xpState()).toBe("calculating"); + + // Moved in the DOM: detached and re-attached in the same task. + const fetchMock = stubXpEndpoint([() => json(eligible())]); + modal.remove(); + document.body.appendChild(modal); + await settle(); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(xpState()).toBe("result"); + }); + it("skips from the keyboard with a real button", async () => { stubXpEndpoint([() => json(eligible())]); await mount(makeGame({ ended: true }));