diff --git a/app/features/events/events-hooks.browser.test.tsx b/app/features/events/events-hooks.browser.test.tsx index 9c7fc9110..267ebbe24 100644 --- a/app/features/events/events-hooks.browser.test.tsx +++ b/app/features/events/events-hooks.browser.test.tsx @@ -143,7 +143,8 @@ describe("useServerEventListener", () => { }); }); -const CATCH_UP_HIDDEN_MS = 20 * 1000; +const CATCH_UP_AWAY_MS = 20 * 1000; +const FOREGROUND_TICK_MS = 5 * 1000; const EVENTS_DOWN_CATCH_UP_MS = 2 * 60 * 1000; const CATCH_UP_MAX_JITTER_MS = 3_000; const LATE_FIRST_CONNECT_MS = 2_000; @@ -179,6 +180,12 @@ const advanceTimers = async (ms = 0) => { }); }; +/** Time passing without the page running, the way a suspended or asleep device does. */ +const sleepingDevice = async (ms: number) => { + vi.setSystemTime(Date.now() + ms); + await advanceTimers(); +}; + const setVisibility = (state: DocumentVisibilityState) => { Object.defineProperty(document, "visibilityState", { configurable: true, @@ -265,7 +272,7 @@ describe("useEventStreamCatchUp", () => { await helloArrives(); setVisibility("hidden"); - await advanceTimers(CATCH_UP_HIDDEN_MS); + await advanceTimers(CATCH_UP_AWAY_MS); setVisibility("visible"); await advanceTimers(CATCH_UP_MAX_JITTER_MS); @@ -277,13 +284,44 @@ describe("useEventStreamCatchUp", () => { await helloArrives(); setVisibility("hidden"); - await advanceTimers(CATCH_UP_HIDDEN_MS / 2); + await advanceTimers(CATCH_UP_AWAY_MS / 2); setVisibility("visible"); await advanceTimers(CATCH_UP_MAX_JITTER_MS); expect(catchUps).toBe(0); }); + test("catches up when an app the phone suspended announces it is back", async () => { + await mountConnecting(); + await helloArrives(); + + // suspending stops the page without handing it the hidden transition first + await sleepingDevice(CATCH_UP_AWAY_MS); + setVisibility("visible"); + await advanceTimers(CATCH_UP_MAX_JITTER_MS); + + expect(catchUps).toBe(1); + }); + + test("catches up when the page resumes without announcing it at all", async () => { + await mountConnecting(); + await helloArrives(); + + await sleepingDevice(CATCH_UP_AWAY_MS); + await advanceTimers(FOREGROUND_TICK_MS + CATCH_UP_MAX_JITTER_MS); + + expect(catchUps).toBe(1); + }); + + test("does not catch up while merely sitting in the foreground", async () => { + await mountConnecting(); + await helloArrives(); + + await advanceTimers(CATCH_UP_AWAY_MS * 5); + + expect(catchUps).toBe(0); + }); + test("catches up on an interval while the event stream is down", async () => { await mountConnecting(); diff --git a/app/features/events/events-hooks.ts b/app/features/events/events-hooks.ts index 17c1fdaa3..98e709905 100644 --- a/app/features/events/events-hooks.ts +++ b/app/features/events/events-hooks.ts @@ -5,8 +5,11 @@ import { } from "~/features/events/events-client"; import type { ServerEvent } from "~/features/events/events-types"; -// how long the tab must have been hidden for returning to it to be worth a catch-up -const CATCH_UP_HIDDEN_MS = 20 * 1000; +// how long the page must have been away for coming back to it to be worth a catch-up +const CATCH_UP_AWAY_MS = 20 * 1000; +// how often the page marks itself as running in the foreground; the gap left in these +// ticks is what an absence is measured by, so it bounds how much of one goes unnoticed +const FOREGROUND_TICK_MS = 5 * 1000; // how often to catch up while the event stream is down and nothing can arrive over it const EVENTS_DOWN_CATCH_UP_MS = 2 * 60 * 1000; // spreads out the catch-ups of the many clients that reconnect at once after a deploy @@ -52,7 +55,7 @@ export function useEventsTopic(topic: string, enabled = true) { /** * Calls `onCatchUp` whenever events may have been missed: the stream came back up, the - * tab was returned to after being hidden long enough, or the stream is down and nothing + * page was returned to after being away long enough, or the stream is down and nothing * can arrive over it at all. Returns the same trigger for callers that have a reason of * their own to catch up. * @@ -96,25 +99,7 @@ export function useEventStreamCatchUp({ React.useEffect(() => { if (!enabled) return; - let hiddenAt: number | null = null; - - const handleVisibilityChange = () => { - if (document.visibilityState !== "visible") { - hiddenAt = Date.now(); - return; - } - - // a quick tab away can not have missed anything the stream would not - // still deliver, and catching up for it would be pure server load - if (hiddenAt !== null && Date.now() - hiddenAt >= CATCH_UP_HIDDEN_MS) { - catchUp(); - } - hiddenAt = null; - }; - - document.addEventListener("visibilitychange", handleVisibilityChange); - return () => - document.removeEventListener("visibilitychange", handleVisibilityChange); + return subscribeToPageReturn(catchUp); }, [enabled, catchUp]); React.useEffect(() => { @@ -165,4 +150,53 @@ function useCatchUpOnConnect( }, [enabled, readyState, onConnect]); } +const returnListeners = new Set<() => void>(); +let foregroundTicker: ReturnType | null = null; +let lastForegroundTickAt = 0; + +/** + * Notifies every listener when the page comes back from being away long enough that the + * event stream can not be trusted to have delivered everything: a backgrounded tab, an + * app the phone suspended, a sleeping device. Returns an unsubscribe function. + * + * Time away is the gap between the ticks of a heartbeat that only runs while the page is + * in the foreground, since a suspended page is never handed the transition to hidden that + * a return would otherwise be measured against. + */ +function subscribeToPageReturn(listener: () => void) { + returnListeners.add(listener); + if (returnListeners.size === 1) { + lastForegroundTickAt = Date.now(); + foregroundTicker = setInterval(noticeReturn, FOREGROUND_TICK_MS); + document.addEventListener("visibilitychange", noticeReturn); + // bfcache restores resume the page without a visibility change of their own + window.addEventListener("pageshow", noticeReturn); + } + + return () => { + returnListeners.delete(listener); + if (returnListeners.size > 0) return; + + if (foregroundTicker !== null) clearInterval(foregroundTicker); + foregroundTicker = null; + document.removeEventListener("visibilitychange", noticeReturn); + window.removeEventListener("pageshow", noticeReturn); + }; +} + +function noticeReturn() { + if (document.visibilityState !== "visible") return; + + const awayFor = Date.now() - lastForegroundTickAt; + lastForegroundTickAt = Date.now(); + + // a quick tab away can not have missed anything the stream would not still + // deliver, and catching up for it would be pure server load + if (awayFor < CATCH_UP_AWAY_MS) return; + + for (const listener of returnListeners) { + listener(); + } +} + const getServerReadyState = (): EventsReadyState => "CLOSED"; diff --git a/changelog/2026-08-30-catch-up-after-sleep.md b/changelog/2026-08-30-catch-up-after-sleep.md new file mode 100644 index 000000000..34a22fed8 --- /dev/null +++ b/changelog/2026-08-30-catch-up-after-sleep.md @@ -0,0 +1,4 @@ +--- +type: bug +--- +Pages now catch up on what they missed when you come back to them, instead of showing stale data until you refresh (new chat regression) diff --git a/e2e/pages/tournament/tournament-brackets-page.ts b/e2e/pages/tournament/tournament-brackets-page.ts index 23d539cd0..7b64bcc24 100644 --- a/e2e/pages/tournament/tournament-brackets-page.ts +++ b/e2e/pages/tournament/tournament-brackets-page.ts @@ -66,6 +66,11 @@ export class TournamentBracketsPage { return this.page.locator(`[data-match-id="${matchId}"]`); } + /** Both sides' scores of the match, top to bottom. */ + matchScores(matchId: number) { + return this.match(matchId).getByTestId("match-score"); + } + /** The match's countdown timer, a sibling of the match link. */ matchTimer(matchId: number) { return this.match(matchId).locator("..").getByTestId("bracket-match-timer"); diff --git a/e2e/tournament-bracket.spec.ts b/e2e/tournament-bracket.spec.ts index ebc8b903e..c64c768f9 100644 --- a/e2e/tournament-bracket.spec.ts +++ b/e2e/tournament-bracket.spec.ts @@ -1,6 +1,10 @@ +import type { Page } from "@playwright/test"; +import { NZAP_TEST_ID } from "~/db/seed/constants"; import { ADMIN_ID } from "~/features/admin/admin-constants"; +import { openSecondUser } from "./helpers/chat"; import { expect, impersonate, isNotVisible, test } from "./helpers/playwright"; import { + createInProgressMatch, createTeams, startedTournamentTimes, teamSeeds, @@ -191,4 +195,58 @@ test.describe("Tournament bracket", () => { // Match is now finalized (no longer ongoing) → "Final" appears in banner await expect(match.locators.finalBanner).toBeVisible(); }); + + test("shows a result reported while the app was suspended", async ({ + page, + browser, + workerBaseURL, + factories, + }) => { + test.slow(); + const { tournament, matchId } = await createInProgressMatch(factories, { + name: "Backgrounded Cup", + friendId: NZAP_TEST_ID, + }); + + // the event stream a suspended app comes back to: still connected as far as + // the page knows, but no longer subscribed to anything, so the broadcast of + // the result below never reaches it + await page.route(/\/sse\/[^/]+\/topics$/, (route) => route.abort()); + + await impersonate(page, NZAP_TEST_ID); + const brackets = new TournamentBracketsPage(page); + await brackets.goto(tournament.id); + + await expect(brackets.matchScores(matchId)).toHaveText(["0", "0"]); + + const organizer = await openSecondUser(browser, workerBaseURL); + try { + const matchPage = new TournamentMatchPage(organizer.page); + await matchPage.goto({ tournamentId: tournament.id, matchId }); + await matchPage.openTab("action"); + await matchPage.reportResult({ mapsToReport: 1, setEnds: false }); + + // the page missed it, the same way a phone with its screen off does + await expect(brackets.matchScores(matchId)).toHaveText(["0", "0"]); + + await deviceAsleep(page, "10:00"); + + await expect(brackets.matchScores(matchId)).toHaveText(["1", "0"], { + timeout: 15_000, + }); + } finally { + await organizer.close(); + } + }); }); + +/** + * The device sleeping for the given time: the page's clock jumps forward without it + * having run, the way a phone suspending a PWA leaves it — no transition to hidden + * on the way out, and no announcement of the return either. + */ +async function deviceAsleep(page: Page, duration: string) { + await page.clock.install(); + await page.clock.fastForward(duration); + await page.clock.resume(); +}