Fix stale data when coming back to the app after it being in the background

This commit is contained in:
Kalle
2026-08-30 13:51:51 +03:00
parent a5a25aefd4
commit f12aabd38d
5 changed files with 164 additions and 25 deletions

View File

@@ -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();

View File

@@ -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<typeof setInterval> | 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";

View File

@@ -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)

View File

@@ -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");

View File

@@ -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();
}