diff --git a/src/queries/ChoreQueries.jsx b/src/queries/ChoreQueries.jsx index 599aa27..3ca3059 100644 --- a/src/queries/ChoreQueries.jsx +++ b/src/queries/ChoreQueries.jsx @@ -99,7 +99,8 @@ export const useChores = (includeArchive = false) => { queryFn: async () => { if (isOfflineFeatureEnabled()) { try { - // Sync from server first (no-op if already syncing or offline) + // Sync from server first (coalesced with any run already in flight, + // so a just-created chore can't be missed by a stale cursor) if (networkManager.isOnline) { await syncEngine.sync() } diff --git a/src/utils/SyncEngine.js b/src/utils/SyncEngine.js index 1ce4172..b9143b2 100644 --- a/src/utils/SyncEngine.js +++ b/src/utils/SyncEngine.js @@ -28,6 +28,10 @@ class SyncEngine { constructor() { this.isSyncing = false this.listeners = [] + // The run currently in flight, and the single follow-up run queued behind + // it. See sync() for why a follow-up is needed rather than just waiting. + this.inFlight = null + this.queued = null } // Register listener for sync state changes @@ -42,10 +46,38 @@ class SyncEngine { this.listeners.forEach(cb => cb(state)) } - // Main sync entry point — returns true if sync succeeded, false otherwise + // Main sync entry point — returns true if sync succeeded, false otherwise. + // + // Concurrent callers are coalesced rather than dropped. Returning early while + // another run is in flight used to lose writes: that run's /sync/changes + // request may have been issued *before* the caller's change reached the + // server, so its cursor skips past the change and the caller reads a cache + // that will never contain it until something else triggers a sync. That is + // why a task created from the modal could vanish on the refetch right after + // it was created. Waiting for the in-flight run is not enough for the same + // reason, so callers that arrive mid-run share one follow-up run instead. async sync() { if (!isOfflineFeatureEnabled()) return false - if (this.isSyncing) return false + + if (this.inFlight) { + if (!this.queued) { + this.queued = this.inFlight + .catch(() => false) + .then(() => { + this.queued = null + return this.sync() + }) + } + return this.queued + } + + this.inFlight = this._runSync().finally(() => { + this.inFlight = null + }) + return this.inFlight + } + + async _runSync() { this.isSyncing = true this._notify({ syncing: true, error: null })