mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-12 14:16:04 -05:00
Fix stale data when coming back to the app after it being in the background
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
4
changelog/2026-08-30-catch-up-after-sleep.md
Normal file
4
changelog/2026-08-30-catch-up-after-sleep.md
Normal 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)
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user