Repository navigation
web: send the workout "left" signal when the page goes away, and say why - #403
Merged
DuarteSantos8 merged 1 commit intoOct 9, 2026
Merged
Conversation
The Workout screen sent its "left" signal (POST /api/activity { active:
false }, which drops the athlete from the admin dashboard's "Training now")
only from its effect cleanup. That runs on in-app navigation, where the
api() call beside it sends the same thing anyway. A closed tab, or a
home-screen app swiped away or killed by the phone, unmounts nothing, so
the athlete stayed under "Training now" until the server's presence
expiry, 70 s after the last heartbeat.
The screen now sends it through beacon() (navigator.sendBeacon, unchanged)
on pagehide, and on visibilitychange to hidden as well, because iOS kills
a hidden home-screen app without a pagehide. The signal carries a reason
the server acts on: `navigated` from the effect cleanup, `closed` from
pagehide, `hidden` from visibilitychange, which the server keeps listed
for 45 s unless a heartbeat arrives -- a phone locked between sets keeps
heartbeating and is never blinked off the list, while an iOS app swiped
away is gone in 45 s instead of the full 70 s expiry. Each reason is sent
once per hide, re-armed by a heartbeat; a close whose pagehide follows a
hidden visibilitychange still sends `closed`, and nothing follows `closed`.
A page shown again, or restored from the back/forward cache, heartbeats
at once.
api 7 new tests (server-presence.test.js, api/test/fake-clock.mjs drives
the spawned server's clock over IPC so each step lands at an exact
server time). frontend 10 new tests in Workout.test.jsx. All pass.
Tested by hand against the real app (register, start a freestyle workout,
close the tab or navigate away) in Chromium 153.0.8010.12 and WebKit 26.6
via Playwright, and separately with an isolated navigator.sendBeacon
harness against a plain local server mirroring the same JS. On
navigating away, both browsers' isolated-harness delivery was 100%
reliable (18 of 18 standard page-close trials across two runs, plus 10
of 10 with 150ms of added server latency, and equally reliable navigating
to another URL). The real-app run's own network observer caught the
request from WebKit but not consistently from Chromium; the isolated
harness's server-side receipt log did catch it every time for both, which
points at a gap in how Playwright surfaces a beacon fired right at page
teardown rather than at an actual delivery failure. Unable to reproduce a
WebKit-specific loss on tab close that the browser itself, rather than
the observation method, would be responsible for.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The Workout screen sent its "left" signal (
POST /api/activity { active: false }, which drops the athlete from the admin dashboard's "Training now") onlyfrom its effect cleanup. That runs on in-app navigation, where the
api()call beside it sends the same thing anyway. A closed tab, or a home-screen
app swiped away or killed by the phone, unmounts nothing, so the athlete
stayed under "Training now" until the server's presence expiry, 70 s after
the last heartbeat.
The screen now sends it through
beacon()(stillnavigator.sendBeacon,unchanged) on
pagehide, and onvisibilitychangeto hidden as well,because iOS kills a hidden home-screen app without a
pagehide. The signalnow carries a reason the server acts on:
navigatedfrom the effectcleanup,
closedfrompagehide,hiddenfromvisibilitychange. Theserver keeps a
hiddenathlete listed for 45 s unless a heartbeat arrives.A phone locked between sets keeps heartbeating and is never blinked off the
list, while an iOS app swiped away is gone in 45 s instead of the full 70 s
expiry. Each reason is sent once per hide, re-armed by a heartbeat; a close
whose
pagehidefollows ahiddenvisibilitychange still sendsclosed,and nothing follows
closed. A page shown again, or restored from theback/forward cache, heartbeats at once.
api 7 new tests (
server-presence.test.js; a newtest/fake-clock.mjsdrives the spawned server's clock over IPC so each step in the test lands at
an exact server time), run with
node --test. frontend 10 new tests inWorkout.test.jsx, run withnpx vitest run --maxWorkers=2. 129 total inthat file, all passing.
Tested in real Chromium 153 and WebKit 26.6 through Playwright. With a bare page and a
server that logs what arrives, the beacon arrived on every tab close in both browsers (18 of
18, and 10 of 10 with 150 ms of added latency) and on every navigation away. In a separate
run of the full app with WebKit on macOS, a tab close lost the request in 4 of 4 tries, and
a keepalive
fetchin place of the beacon lost it the same way, so that may be Playwrighttearing the page down rather than Safari. The
hiddenreason is what covers a home-screenapp iOS suspends or kills, which Playwright cannot exercise.
Conflicts with #312 (generic prescription engine v2) in
Workout.test.jsx,where both add new test blocks at the same places. Keeping both resolves it.
🤖 Generated with Claude Code