«El bug se arregló, pero sigue ahí» — así se veía desde fuera. La queja del propietario sonaba simple: «los datos no se actualizan al entrar en la app — hay que tirar a mano para que se actualicen el tiempo y todo lo demás». Un síntoma, una frase, la expectativa de una sola corrección.
MeteoHealth (ficha del proyecto) es una app en el App Store, y una queja así normalmente sí que cuesta una línea en el lugar equivocado. Encontré la causa, la corregí, los tests se pusieron en verde — y la queja desapareció. Durante doce días.
El 22 de agosto volvió casi palabra por palabra, y esta vez detrás del síntoma «no se actualiza» aparecieron cuatro causas independientes, más una quinta engendrada por el propio primer fix. Lo que sigue no es la historia de un bug, sino la historia de qué hacer cuando la reparación no se sostiene.
Causa 1: .task vive más de lo que parece#
El primer hallazgo fue casi obvio a posteriori. La pantalla «Hoy» cargaba los datos en .task, y dentro de un TabView el .task se ejecuta una vez por vida del proceso: las pestañas permanecen montadas, y ni cambiar de pestaña ni volver del segundo plano lo relanzan.
Nadie escuchaba scenePhase en las pantallas en absoluto — a nivel de la app estaba ocupado solo con las notificaciones y los deep links. Un comentario en el código lo registra directamente:
// Motivo: «los datos no se actualizan al entrar en la app — hay que tirar
// a mano». La causa era que la carga colgaba de `.task`, que dentro de un
// `TabView` se ejecuta una vez por vida del proceso: las pestañas permanecen
// montadas, y ni cambiar de pestaña ni volver del segundo plano lo
// relanzan.El fix del 10 de agosto (952975c) añadió el modificador .refreshOnForeground a cuatro pestañas — «Hoy», «Pronóstico», «Diario», «Analítica» — ligado a la transición de scenePhase a .active. Eso cerró «se fue al segundo plano y volvió». No cerró «cambió a otra pestaña y volvió sin salir de la app ni una vez» — el mismo .task seguía callado.
Esa mitad de la causa afloró solo el 22 de agosto, como punto aparte del mismo síntoma: «volver a la pestaña no actualizaba nada: la puerta solo saltaba tras irse al segundo plano». Un mecanismo, dos disparadores, cerrados en dos tandas.
Causa 2: una caché sin puerta trasera#
La segunda causa estaba en ese mismo primer fix: WeatherService mantenía una caché rígida de diez minutos sin bypass. Aunque la pantalla relanzara honestamente la carga, updateWeatherData() devolvía en silencio los mismos valores de la caché — un pull-to-refresh lanzado a mano no cambiaba nada. La pantalla se redibujaba, el spinner giraba, los datos seguían siendo los de ayer — para el usuario eso es indistinguible de «no funciona en absoluto».
/// - Parameter force: saltarse la caché de diez minutos. Se pone allí donde la
/// actualización la pide una persona (pull-to-refresh) o el regreso de la app
/// del segundo plano: si no, un «tirón» dentro de la ventana de caché
/// reasignaba en silencio los mismos valores, y los datos parecían frescos
/// sin serlo.
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 {El parámetro force no anula la caché por completo — su vigencia se reduce de diez minutos a uno, para no disparar una ráfaga de peticiones contra el límite compartido de la clave de API. El coalescing de llamadas paralelas vía inFlightUpdate quedó tal cual — y resultó importante: fue justo lo que provocó la causa 3.
Causa 3: un force disuelto en la multitud#
Para el 22 de agosto las dos causas anteriores estaban cerradas, y la queja volvió. Al arrancar la app, dos sitios lanzan la actualización a la vez: MainTabView.task y TodayViewModel.loadData — y ambos sin force. Si en esa misma ventana una persona tiraba de la pantalla a mano, su petición forzada simplemente se unía a la ordinaria ya en curso a través de inFlightUpdate y recibía los mismos datos cacheados. El gesto de pull-to-refresh se disparaba físicamente — y no hacía absolutamente nada.
/// Qué hacer con una nueva petición de actualización cuando ya hay una en curso.
///
/// Al arrancar, `MainTabView.task` y `TodayViewModel.loadData` lanzan la
/// actualización a la vez, y ambos sin `force`; si en esa ventana una persona
/// tiraba de la pantalla, su petición forzada se fundía con la ordinaria ajena,
/// esta servía datos de la caché de diez minutos, y el gesto no hacía nada.
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
}
}El coalescing en sí era la idea correcta — sin él, las llamadas paralelas habrían engendrado peticiones de red duplicadas. El error era que no distinguía la fuerza de la petición: una pasada débil y una fuerte colapsaban en un único resultado débil.
Causa 4: el tiempo esperaba una mudanza#
La cuarta causa vivía aparte de las tres primeras, en la resolución de la geolocalización. Con un fix de GPS lento, resolveLocation() chocaba con el timeout de ocho segundos y salía con las manos vacías — y no había reintento, porque didUpdateLocations vuelve a pedir el tiempo solo tras desplazarse más de 5 km. Si la persona no se había mudado a ningún sitio (y normalmente no se había mudado — simplemente abrió la app por la mañana en el mismo lugar), la actualización se apagaba en silencio para siempre. La pantalla se quedaba con la instantánea de ayer hasta un tirón manual — el gesto que a su vez se comía la causa 3.
/// El arranque en frío con un fix lento chocaba con el timeout de ocho segundos
/// y salía con las manos vacías; el intento en sí no se repetía —
/// `didUpdateLocations` vuelve a pedir el tiempo solo tras desplazarse más de
/// 5 km. Dos intentos son el techo: si tras ellos sigue sin haber fix, el
/// problema no es de tiempo.
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)
}
}Más un fallback a la caché de localización del sistema (location ?? currentLocation ?? locationManager.location) — si no hay fix fresco, la última posición conocida suele bastar para el tiempo.
Cuatro causas — y todas cerradas. Quedaba una quinta capa que esas cuatro no contemplaban, porque la había introducido el propio primer fix.
Causa 5: el fix que se adelantó a sí mismo#
El primer fix (952975c) introdujo por sí mismo un bug aparte, encontrado ya al día siguiente durante una comprobación en vivo en el simulador. La puerta RefreshOnForegroundGate calculaba el throttling «por la hora de la última actualización» — y una escena que pasaba a .active justo después del arranque en frío organizaba una segunda carga de la pantalla pisándole los talones a la primera. En los logs se veía así: 01:15:23 y 01:15:26 — dos cargas seguidas con tres segundos de diferencia, aunque la app acababa de abrirse.
La regla se reescribió según cómo sonaba la propia queja: no «cuándo se actualizó», sino «cuánto tiempo pasó la app en segundo plano»:
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
}
}El efecto secundario de la reescritura resultó agradable: de paso cayeron ambos falsos positivos — el arranque en frío (backgroundedAt es simplemente nil, no hay nada que actualizar) y el diálogo del sistema encima de la app, que da .inactive y no .background. Comprobado en los mismos logs: 01:19:50 arranque en frío — una carga; 32 segundos en segundo plano; 01:20:26 regreso — la segunda. Exactamente dos, sin duplicado.
La puerta como tipo puro#
Lo que me gusta de este cambio no es el hecho de la reparación en sí, sino que la decisión «actualizar ahora o no» se sacó desde el principio de la View a un tipo aparte, RefreshOnForegroundGate, sin una sola dependencia de SwiftUI dentro de la lógica. Eso es lo que permitió verificar el throttling, .inactive vs .background y el duplicado repetido con tests unitarios, y no a ojo en el simulador.
Distinguir .inactive de .background no es un detalle casual de la API, sino consecuencia directa de la queja: un diálogo de permisos del sistema o el control center encima de la app da .inactive, y allí los datos objetivamente no llegan a envejecer. Actualizar con cada diálogo así significa tirar de la red sin motivo.
El umbral de 30 segundos vive en dos sitios — en la propia puerta y, duplicado, en un test de UI con un ciclo real de «minimizar — esperar — volver» en el simulador (XCUIDevice.shared.press(.home) → Thread.sleep más largo que el umbral → app.activate()). La duplicación está registrada en un comentario:
/// El umbral de `RefreshOnForegroundGate`. Los tests de UI no pueden hacer
/// `@testable import` de la app (se lanza como proceso aparte), así que el
/// valor está duplicado. Si diverge, el test empezará a volver demasiado
/// pronto y se pondrá rojo, en vez de mentir.
private enum RefreshOnForegroundThreshold {
static let seconds: TimeInterval = 30
}El peor escenario para una constante duplicada en dos procesos es la divergencia silenciosa. Aquí la divergencia rompe el test en vez de pasar desapercibida.
Vale la pena hablar también de la honestidad del proceso alrededor de todo esto. En el commit 952975c está escrito directamente: «NO VERIFICADO DEL TODO: no se pudo ejecutar el ciclo interactivo de "minimizar — volver" en el simulador». Y en 22b8068, sobre la regresión del scroll tras el pull-to-refresh: «en el simulador no se pudo reproducir la regresión original — el test está en verde incluso sobre el código revertido — así que la corrección solo puede confirmarse en un dispositivo». Registrar la frontera de lo verificado es parte de la misma disciplina que el propio fix: un test que no puede demostrar que el bug está cerrado se llama honestamente guardián contra roturas futuras, y no prueba.
Cómo terminó esta historia#
Cuando de un bug dicen «lo arreglaron pero sigue ahí», casi nunca significa «el fix no funcionó». Significa que hay más de una causa, y que no las une la lógica, solo el síntoma. Aquí eran cuatro capas independientes — el lifecycle de SwiftUI (.task en un TabView), la caché del servicio sin bypass, el coalescing de peticiones paralelas y el disparador geográfico por distancia — y cada una enmascaraba a las demás: arregla .task, y el pull-to-refresh sigue callado por culpa de la caché; arregla la caché, y al arrancar se la come el coalescing; arregla el coalescing, y sin el fix de localización la pantalla sigue sin actualizarse hasta que el GPS responda. Más, aparte, el bug que introdujo el propio primer fix, encontrado solo cuando por fin llegaron las manos al simulador para comprobarlo en vivo, y no por los logs de compilación.
La conclusión práctica es más concreta que «escribe más tests»: la decisión de sacar la lógica de la View a un tipo puro se amortizó sola — fue justo lo que hizo el throttling verificable sin capturas de pantalla. Y una segunda: si el síntoma está descrito en una sola frase del usuario, no significa que la causa sea una — vale la pena comprobar cada capa por aislado antes de cerrar el ticket.


