Repository navigation
fix(sync, sw, update): a change that is saved nowhere, a build swept off the device, and an update that is never offered - #250
Closed
kurktchiev wants to merge 5 commits into
Closed
kurktchiev wants to merge 5 commits into
kurktchiev wants to merge 5 commits into
Conversation
kurktchiev
force-pushed
the
gh/sync-and-shell
branch
from
September 19, 2026 11:43
4f16a5c to
8b611b1
Compare
Two tabs of one browser share the saved copy and the revision marker but each hold their own state in memory. The tab left open never learned that the other one had saved: its next change went out as its whole stale state quoting the marker the other tab had just written, so the server took it without a 409 and the workout logged in the other tab was gone from both. It now listens for the save. A change this tab still owes is merged in (the same union a 409 uses); otherwise it takes the newer copy, keeping the in-progress workout, which belongs to the tab running it. Only memory is touched: the saved copy is already what the event carried, and writing it back would land in the other tab, which would answer in kind. An event that came from another profile's sign-in is left to the owner listener, which drops this profile rather than merge into the new owner's copy. The push the merge arms waits for boot behind the same gate persist uses: before boot has pulled, the copy in hand may be older than the server's and the marker stale with it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A new worker whose precache failed (the server restarting mid-deploy, a weak signal, an auth proxy in front answering with its login page) still activated and deleted the previous build's cache, so opening the app without a network showed the browser's error page until the next online load. The install now fails instead of returning quietly, and the sweep waits until this build's shell is really in its own cache. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A version that says which build it came from carries it as semver build metadata: "1.3.8+2026-09-18.2". Split on ".", that makes the patch NaN, which read as 0, so a release tagged that way compared as 1.3.0, and Settings → Check for updates found nothing against a running 1.3.7. Semver keeps build metadata out of precedence, and so does the comparison now, on the tag side as well as the running build's. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
persist wrote to localStorage before it told the store, so a refused write (a device genuinely full, a private window with almost no quota) took the change with it: Finish did nothing at all, and said nothing either. The copy is now kept in memory whatever the write does, marked as owed to the server so a signed-in device still gets it there, and the one thing the screen cannot show on its own is said out loud once per streak. New string in all fourteen locale packs; pt-BR takes it as an override, so the inherited pt-PT fingerprint is untouched and only its count moves. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…that worked
Every sub-resource the shell references was cached with
`c.add(u).catch(() => {})`. So an install that fetched index.html and then lost
one script to a flaky connection, the phone that walked out of wifi mid-update,
reported success, activated, and swept the previous build's cache, which is the
only thing a home-screen app reopened without a network has to come back from.
The next offline open was the shell with no code behind it. A blank page until
the device was online again, with no way in from the app itself.
The scripts and the stylesheets are the app, so one of them failing to cache now
fails the install. Nothing is lost when an install fails: the worker is thrown
away and the build already on the device, cache and all, stays exactly where it
was and keeps serving. Images, icons and the webmanifest stay best-effort,
because a missing icon is not a broken app.
Each one is fetched by hand rather than with `cache.add`, for the same reason
index.html is. `add` takes a redirect for an answer, and an auth proxy in front
answers every request with its login page once the session there expires. A 200
of HTML stored under the main bundle's URL is worse than nothing cached at all,
because the install would report success and then sweep the build that worked.
The shell itself is written to the cache last, after its own code. Activate's
guard is "an index.html in THIS build's cache"; with index.html written first, a
cache holding nothing but the shell satisfied that guard, and an activate that
ran anyway would have swept the working build on the strength of a file that
could not boot on its own.
Three cases added: a 404 on the chunk fails the install, names the file it could
not get, leaves this build's cache without a shell and the previous build's
cache untouched; a redirect to a login page fails it the same way; a 404 on an
icon installs, activates and sweeps as usual.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
kurktchiev
force-pushed
the
gh/sync-and-shell
branch
from
September 21, 2026 19:34
8b611b1 to
eba2364
Compare
1 task
DuarteSantos8
added a commit
that referenced
this pull request
Sep 28, 2026
5 tasks done
Owner
|
Thanks @kurktchiev. Sections 2–5 are in v1.3.9 as you wrote them: an install without the shell no longer sweeps the working build, a lost chunk fails the install, the update check parses Section 1, the stale tab (#283), is fixed differently. Each copy now tracks its own base revision, and there's a revision check when the Released in v1.3.9: https://github.com/DuarteSantos8/openGym/releases/tag/v1.3.9 |
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.
Five fixes, one commit each, all with tests. Four of them are about a change or a build going missing
without saying anything; the other is a two-character parser bug in the update check.
Sections 2 and 5 are the same file and belong together. The rest touch separate files and rebase
independently, so I am happy to split any of them out.
1. A tab left open pushes its stale state over the other tab's work
What breaks. Two tabs of one browser share the saved copy (
gym_state_v1) and the revision marker(
gym_sync), but each holds its ownSin memory. The tab that was left open never learned that theother one had saved. Its next change went out as its whole stale state, quoting the marker the other
tab had just written, so the server saw a current
baseRev, took the write without a 409, and theworkout logged in the other tab was gone from both copies. The conditional write cannot help here. The
stale tab is quoting the right number, it just does not have the data behind it.
Reproduce. Open the app in two tabs, signed in. In tab B, log and finish a workout. In tab A
(untouched since before that), change any setting. Tab B's workout is gone from tab A, from the
server, and from tab B on its next pull.
What changed. A
storagelistener onKEY. A change this tab still owes is merged in with thesame
mergeStatesunion a 409 already uses; otherwise it takes the newer copy. The in-progressworkout stays with the tab running it (
next.active = S.active). Only memory is touched. The savedcopy is already what the event carried, and writing it back would land as a storage event in the other
tab, which would answer in kind.
An event whose
gym_ownerno longer matches this tab's user is left alone, because another profilesigning in elsewhere writes its wiped copy through this same key just before it records itself as
owner; that case belongs to the existing
gym_ownerlistener, which drops this profile rather thanmerging it into the new owner's copy. The push the merge arms waits for
readybehind the same gatepersistuses, since before boot has pulled, the copy in hand may be older than the server's and themarker stale with it.
The
owedexpressioncheckRevalready had is now a namedowes()helper, since both places ask thesame question.
2. A service worker that never got the shell still swept the build off the device
What breaks.
precache()ended withif (!res.ok) return, andinstallwrapped it in.catch(() => {}). So a new worker whose precache failed still resolved its install, calledskipWaiting(), activated, andactivatedeleted every cache that was notCACHE. A precache failswhen the server restarts mid-deploy, when a phone has a weak signal, or when an auth proxy in front
(Authelia, Cloudflare Access;
docs/SELF_HOSTING.md) answers with its login page once the sessionthere expires. The previous build's shell is what a Home Screen app reopened without a network comes
back from, so after that the app opened on the browser's error page until the next load with a
network.
Reproduce. With the app installed and working offline, deploy while the client is installing the
new worker (or block
index.htmlat the proxy). Then go offline and reopen. Error page.What changed.
precache()throws instead of returning quietly, so the install fails, which is theonly thing that keeps the build already on the device in place. A response that is
redirectedistreated the same as a bad status. Whatever came back is not the shell, and caching a login page as
index.htmlis worse than not installing.activatesweeps the older caches only once this build'sshell is really in its own cache, so an activate that happens anyway leaves the working build alone.
3. A release tag that says which build it is is never offered as an update
What breaks.
compareSemversplits on".". A version carrying semver build metadata,"1.3.8+2026-09-18.2", splits to["1","3","8+2026-09-18","2"], so the patch isNaN, which(pa[i] || 0)reads as0. A release tagged that way therefore compares as1.3.0. Against arunning
1.3.7,hasUpdateisfalseand the update is never offered. The same blind spot from theother side has the check offer an update to the release already installed, when it is the running
build that carries the metadata.
Reproduce. On
mainas it stands, the added test fails.hasUpdateisfalsefor a release onepatch ahead whose tag carries build metadata:
× judges a tag that carries build metadata on its numbers alone … expected false to be true.What changed. The metadata is dropped from both operands before the split. Semver says it plays no
part in precedence, so
"1.3.7+anything"and"1.3.7"are the same version here. Two characters;latestVersionis still echoed exactly as the tag came.4. A save this device refuses swallows the change
What breaks.
persistwrote tolocalStoragebefore it told the store. A refused write, from adevice genuinely full or a private window with almost no quota, threw out of
persist, soset({ S })never ran. Finish did nothing at all, and said nothing either. The set you just loggedwas gone from the screen as well as from the disk.
Reproduce. Fill the origin's quota (or use a private window with little of it), then finish a
workout. Nothing happens, and there is no error.
What changed. The write is wrapped. The copy is kept in memory whatever the write does, and marked
gym_dirtyso a signed-in device still gets the change to the server, the one place it can still besafe. It is said out loud once per refusal streak, in the same shape as the existing 413 "upload too
large" toast and reset as soon as a write succeeds, because a change that is not on the device is the
one thing the screen cannot show on its own.
One new user-facing string, in all fourteen locale packs. pt-BR takes it as an override, so the
inherited pt-PT fingerprint is untouched and only its override count moves (644 → 645).
5. An install that lost one chunk still swept the build that worked
What breaks. Section 2 made a failed
index.htmlfail the install. Every sub-resource the shellreferences was still cached with
c.add(u).catch(() => {}). So an install that fetchedindex.htmland then lost one script to a flaky connection, the phone that walked out of wifi mid-update, reported
success, activated, and swept the previous build's cache, the only thing a Home Screen app reopened
without a network has to come back from. The next offline open was the shell with no code behind it. A
blank page until the device was online again, with no way in from the app itself.
Reproduce. Install the app, then deploy and let the worker fetch
index.htmlbut fail oneassets/index-*.js. Go offline and reopen. Blank.What changed. The scripts and the stylesheets are the app, so one of them failing to cache now
fails the install. Nothing is lost when an install fails: the worker is thrown away and the build
already on the device, cache and all, stays exactly where it was and keeps serving. Images, icons and
the webmanifest stay best-effort, because a missing icon is not a broken app.
Each one is fetched by hand rather than with
cache.add, for the same reasonindex.htmlis.addtakes a redirect for an answer, and an auth proxy in front answers every request with its login page
once the session there expires. A 200 of HTML stored under the main bundle's URL is worse than nothing
cached at all, because the install would report success and then sweep the build that worked.
The shell itself is written to the cache last, after its own code. Activate's guard is "an
index.htmlin THIS build's cache"; withindex.htmlwritten first, a cache holding nothing but theshell satisfied that guard, so an activate that ran anyway would have swept the working build on the
strength of a file that cannot boot on its own.
Tests
useStore.sync.test.jsx, "another tab of the same browser saves": takes the newer copy whilekeeping its own in-progress workout and then pushes both sides' work at the right
baseRev; mergesinto a change it still owes and pushes the two together; waits for boot before pushing that merge;
and leaves the copy alone when the owner changed or nothing newer was saved.
sw.test.js(new):frontend/public/sw.jsagainst a stand-in worker environment, with fakecachesand an awaitedwaitUntil, so a rejection really is the install failing. A precache thatcannot reach the server, a redirect to a login page, and a 502 all fail the install and leave the
previous build's cache in place; an install that got the shell takes over and drops it.
update.test.js: one case, where a tag carrying build metadata is judged on its numbers alone, inall four directions (ahead, equal, behind, a major ahead), with
latestVersionechoed as it came.sw.test.jsagain, for section 5: a 404 on a chunk fails the install, names the file it could notget, leaves this build's cache without a shell and the previous build's cache untouched; a redirect
to a login page fails it the same way; a 404 on an icon installs, activates and sweeps as usual.
useStore.restore.test.jsx, "a save this device refuses": the change survives in memory, is markedowed, and the toast is said once per streak and again after a write succeeds in between.
Checklist
npx vitest runpasses infrontend/: 1481 passing across 106 files (mainis 1468 across105; +13 tests, +1 file)
npm testinapi/unchanged: 181 passing, same asmainnpx vite buildsucceeds (no new warnings; theuseUI.jsdynamic-import notice is alreadythere on
main)node scripts/check-locales.mjs: 14 locales, 1292keys each, in sync (one string added)
node scripts/pt-br-inheritance-fingerprint.mjsfingerprint unchanged: the new key is a pt-BRoverride, so nothing inherited moved
CHANGELOG.mdis left alone🤖 Generated with Claude Code