"The bug was fixed, but it's still there" — that's how it looked from the outside. The owner's complaint sounded simple: "the data doesn't refresh when you open the app — you have to pull it manually to update the weather and everything else." One symptom, one sentence, the expectation of a one-line fix.
MeteoHealth (project page) is an app on the App Store, and a complaint like this usually really does cost one line in the wrong place. I found the cause, fixed it, the tests went green — and the complaint went away. For twelve days.
On August 22 it came back almost word for word, and this time behind the symptom "doesn't refresh" there were four independent causes, plus a fifth one spawned by the first fix itself. What follows is not the story of one bug, but the story of what to do when a fix doesn't stick.
Cause 1: .task lives longer than it seems#
The first find was almost obvious in hindsight. The Today screen loaded its data in .task, and inside a TabView a .task fires once per process lifetime: the tabs stay mounted, and neither switching tabs nor returning from the background restarts it.
Nobody listened to scenePhase on the screens at all — at the app level it was busy only with notifications and deep links. A comment in the code records this directly:
// Trigger: "the data doesn't refresh when you open the app — you have to
// pull it manually." The cause was that loading hung on `.task`, which inside
// a `TabView` fires once per process lifetime: the tabs stay mounted, and
// neither switching tabs nor returning from the background restarts it.The August 10 fix (952975c) added a .refreshOnForeground modifier to four tabs — Today, Forecast, Diary, Analytics — keyed to scenePhase transitioning to .active. That closed "went to the background and came back." It did not close "switched to another tab and back without ever leaving the app" — the same .task kept silent.
That half of the cause surfaced only on August 22, as a separate item under the same symptom: "returning to a tab refreshed nothing: the gate only fired after going to the background." One mechanism, two triggers, closed in two passes.
Cause 2: a cache with no back door#
The second cause sat in that same first fix: WeatherService held a hard ten-minute cache with no bypass. Even when the screen honestly restarted its load, updateWeatherData() quietly returned the same values from the cache — a pull-to-refresh triggered by hand changed nothing. The screen re-rendered, the spinner spun, the data stayed yesterday's — to the user this is indistinguishable from "doesn't work at all."
/// - Parameter force: bypass the ten-minute cache. Set wherever a refresh is
/// requested by a human (pull-to-refresh) or by the app returning from the
/// background: otherwise a manual re-pull inside the cache window silently
/// reassigned the same values, and the data looked fresh without being fresh.
func updateWeatherData(force: Bool = false) async {
...
let lifetime = force ? Self.forcedCacheLifetime : Self.cacheLifetime
if let entry = cache.object(forKey: cacheKey as NSString),
Date().timeIntervalSince(entry.value.timestamp) < lifetime {The force parameter doesn't cancel the cache entirely — its lifetime drops from ten minutes to one, so as not to fire a burst of requests into the shared API-key limit. The coalescing of parallel calls via inFlightUpdate stayed as it was — and that turned out to matter: it's exactly what provoked cause 3.
Cause 3: a force dissolved in the crowd#
By August 22 both causes above were closed, and the complaint came back. On app launch two places start a refresh simultaneously: MainTabView.task and TodayViewModel.loadData — both without force. If a person pulled the screen by hand in that same window, their forced request simply joined the ordinary one already in flight via inFlightUpdate and received the same cached data. The pull-to-refresh gesture physically fired — and did precisely nothing.
/// What to do with a new refresh request while one is already in flight.
///
/// On launch, `MainTabView.task` and `TodayViewModel.loadData` start a refresh
/// simultaneously, both without `force`; if a person pulled the screen in that
/// window, their forced request merged into someone else's ordinary one, which
/// served data from the ten-minute cache, and the gesture did nothing.
enum WeatherUpdateCoalescer {
enum Decision: Equatable {
case start
case join
case joinThenForce
}
static func decide(incomingForce: Bool, inFlightForce: Bool?) -> Decision {
guard let inFlightForce else { return .start }
return incomingForce && !inFlightForce ? .joinThenForce : .join
}
}Coalescing itself was the right idea — without it, parallel calls would have spawned duplicate network requests. The mistake was that it didn't distinguish the strength of a request: a weak and a strong pass collapsed into a single weak result.
Cause 4: the weather was waiting for a relocation#
The fourth cause lived apart from the first three, in location resolution. On a slow GPS fix, resolveLocation() ran into the eight-second timeout and came out empty-handed — and there was no retry, because didUpdateLocations re-requests weather only after moving farther than 5 km. If the person hadn't moved anywhere (and they usually hadn't — they'd just opened the app in the morning in the same place), the refresh silently died for good. The screen kept yesterday's snapshot until a manual re-pull — the very gesture that was itself being eaten by cause 3.
/// A cold start on a slow fix ran into the eight-second timeout and came out
/// empty-handed; the attempt itself was never repeated — `didUpdateLocations`
/// re-requests weather only after moving farther than 5 km. Two attempts is
/// the ceiling: if there's still no fix after them, the problem isn't time.
private func scheduleLocationRetryIfNeeded(force: Bool) {
guard authorizationStatus != .denied, authorizationStatus != .restricted else { return }
guard locationRetryCount < Self.maxLocationRetries else { return }
locationRetryCount += 1
Task { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(Self.locationRetryDelay * 1_000_000_000))
await self?.updateWeatherData(force: force)
}
}Plus a fallback to the system's location cache (location ?? currentLocation ?? locationManager.location) — if there's no fresh fix, the last known position is usually good enough for weather.
Four causes — all closed. One fifth layer remained, one the four didn't account for, because it had been introduced by the first fix itself.
Cause 5: The Fix That Outran Itself#
The first fix (952975c) introduced a separate bug of its own, found the very next day during a live check on the simulator. The RefreshOnForegroundGate computed its throttling "by the time of the last refresh" — so a scene that became .active right after a cold start scheduled a second screen load right on the heels of the first. In the logs it looked like this: 01:15:23 and 01:15:26 — two loads in a row, three seconds apart, even though the app had only just opened.
The rule was rewritten to match the way the complaint itself was phrased: not "when the data was last refreshed" but "how long the app spent in the background":
mutating func shouldRefresh(on phase: ScenePhase, now: Date = Date()) -> Bool {
switch phase {
case .background:
backgroundedAt = now
return false
case .active:
guard let wentAway = backgroundedAt else { return false }
backgroundedAt = nil
return now.timeIntervalSince(wentAway) >= minimumBackgroundTime
default:
return false
}
}The rewrite had a pleasant side effect: both false positives fell away at once — the cold start (backgroundedAt is simply nil, nothing to refresh) and a system dialog on top of the app, which yields .inactive, not .background. Verified in the same logs: 01:19:50 cold start — one load; 32 seconds in the background; 01:20:26 return — a second one. Exactly two, no duplicate.
The gate as a pure type#
What I like about this change is not the fix itself, but that the decision "refresh now or not" was moved out of the View into a separate type, RefreshOnForegroundGate, from the very start — with not a single SwiftUI dependency inside the logic. That is what made it possible to verify the throttling, .inactive vs .background, and the repeated duplicate with unit tests rather than by eye on the simulator.
Distinguishing .inactive from .background is not an incidental API detail but a direct consequence of the complaint: a system permission dialog or Control Center on top of the app yields .inactive, and the data objectively has no time to go stale there. Refreshing on every such dialog means hitting the network for no reason.
The 30-second threshold lives in two places — in the gate itself and, duplicated, in a UI test with a real "minimize — wait — return" cycle on the simulator (XCUIDevice.shared.press(.home) → Thread.sleep longer than the threshold → app.activate()). The duplication is recorded in a comment:
/// The threshold from `RefreshOnForegroundGate`. UI tests can't `@testable import`
/// the app (it runs as a separate process), so the value is duplicated. If it
/// drifts, the test will start returning too early and go red rather than lie.
private enum RefreshOnForegroundThreshold {
static let seconds: TimeInterval = 30
}The worst outcome for a constant duplicated across two processes is silent drift. Here, drift breaks the test instead of passing unnoticed.
It's worth saying something about the honesty of the process around all this. Commit 952975c states outright: "NOT FULLY VERIFIED: could not run the interactive minimize-and-return cycle on the simulator." And 22b8068, about the scroll regression after pull-to-refresh: "could not reproduce the original regression on the simulator — the test is green even on the reverted code — so the fix can only be confirmed on a device." Recording the boundary of what was verified is part of the same discipline as the fix itself: a test that cannot prove the bug is closed is honestly called a guard against future breakage, not a proof.
How This Story Ended#
When people say about a bug "it was fixed but it's still there," it almost never means "the fix didn't work." It means there is more than one cause, and they are connected by nothing but the symptom. Here there were four independent layers — the SwiftUI lifecycle (.task in a TabView), a service cache with no bypass, coalescing of parallel requests, and a distance-based geo trigger — and each one masked the others: fix .task, and pull-to-refresh still stays silent because of the cache; fix the cache, and on launch the coalescing eats it; fix the coalescing, and without a location fix the screen still won't refresh until GPS comes through. Plus, separately, the bug introduced by the first fix itself, found only when someone finally got to the simulator to check live rather than by build logs.
The practical takeaway is more specific than "write more tests": the decision to move the logic out of the View into a pure type paid for itself — that is exactly what made the throttling verifiable without screenshots. And a second one: if the symptom is described in a single user sentence, it doesn't mean there is a single cause — each layer is worth checking in isolation before closing the ticket.


