What did you do?
Signed in with a profile, with openGym open in two tabs of the same browser.
- Open openGym in Tab A. Leave it open.
- Open openGym in Tab B in the same browser, same profile.
- In Tab B (or on another device, so that Tab B pulls it), make a change that syncs, e.g. finish a workout. The server revision advances.
- Switch back to Tab A without reloading it. Wait past the 30 s poll. Tab A still shows the old data; the workout is not there.
- In Tab A, make any change: toggle a setting, edit the plan, or send the Coach a message.
- Reload any tab or open another device: the workout from step 3 is gone from the server. Any plan edits made after Tab A last loaded are reverted too.
What did you expect to happen?
Tab A's push should be refused with a 409, because Tab A's data is based on an older revision. The client should then merge, which keeps both sides' workouts. That is what happens when the stale copy is on a second device instead of a second tab.
What happened instead?
Tab A's push is accepted (200), and the server document is replaced wholesale with Tab A's stale in-memory state. A completed, already-synced workout was permanently deleted from the server, and a plan change was reverted. There was no conflict, no merge and no warning.
Why it happens
All references are to frontend/src/store/useStore.js and api/server.js at v1.3.8 (f91cde1).
- The conflict marker is shared between tabs, but the state is per tab.
- Each tab holds its own in-memory state S, which is what it renders and pushes.
- The sync marker gym_sync ({rev, ts}) lives in localStorage and is shared by every tab of the origin (readSync/writeSync, lines 121–122).
- When Tab B pulls or pushes, it advances the shared marker to the current server revision. Tab A's S does not change.
- A stale tab never notices it is stale.
- The 30 s poll (checkRev, ~line 185) compares the server's revision with the shared marker: if (rev !== sync.rev) return get().pullState().
- Tab B keeps that marker current, so Tab A's check always matches and Tab A never pulls.
- The only cross-tab storage listener (line 292) handles gym_owner only. Changes to gym_state_v1 / gym_sync made by other tabs are ignored.
- A stale tab's push passes the server's conflict check.
- doPush (line 217) sends body.baseRev = sync.rev (line 222), again from the shared marker, together with Tab A's stale S.
- The server's check (api/server.js line 936, body.baseRev !== curRev) passes because the borrowed revision is current.
- The document is written as-is: no 409, so the client-side merge (sync-merge.js) never runs.
- The stale tab also overwrites the shared local copy.
- persist (line 145) writes Tab A's S to gym_state_v1.
- The shared local copy is now stale as well: it contains "_rev":36 with gym_sync.rev 58.
How are you running openGym?
Self-hosted (docker compose)
Browser & OS
Safari 27
Is this about login / passkeys?
What did you do?
Signed in with a profile, with openGym open in two tabs of the same browser.
What did you expect to happen?
Tab A's push should be refused with a 409, because Tab A's data is based on an older revision. The client should then merge, which keeps both sides' workouts. That is what happens when the stale copy is on a second device instead of a second tab.
What happened instead?
Tab A's push is accepted (200), and the server document is replaced wholesale with Tab A's stale in-memory state. A completed, already-synced workout was permanently deleted from the server, and a plan change was reverted. There was no conflict, no merge and no warning.
Why it happens
All references are to frontend/src/store/useStore.js and api/server.js at v1.3.8 (f91cde1).
How are you running openGym?
Self-hosted (docker compose)
Browser & OS
Safari 27
Is this about login / passkeys?