La configuración que funcionaba perfecto y se sentía completamente rota
Esta semana agregué una pantalla de Configuración a Deep Cut Atlas. Una sección permite definir el filtro por defecto para la pestaña Discover - mostrar solo álbumes, ocultar sencillos, ese tipo de cosas. La construí, las pruebas unitarias pasaron, los interruptores cambiaban, UserDefaults guardaba. Listo.
Después repasé cómo lo demostraría en la práctica: “cambiar el valor por defecto en Configuración, volver a Discover, y ahí aparece aplicado.” Pero no era así. Al trazar la secuencia real de toques, me di cuenta de que esto iba a sentirse como un error.
Me tomó un segundo entender por qué, porque el código era correcto. Pero “correcto” y “no roto” resultaron ser cosas distintas.
Por qué no pasaba nada
Así es como se configura cada pestaña:
struct DiscoverView: View {
@State private var viewModel: DiscoverViewModel?
var body: some View { /* ... */ }
.task {
if viewModel == nil {
viewModel = DiscoverViewModel(defaultTypes: settings.filterTypes)
await viewModel?.load()
}
}
}
El view model se crea una sola vez - nótese el if viewModel == nil - sembrando su filtro a partir del valor por defecto guardado en ese momento. Ese es un patrón normal.
El detalle está en la palabra “una sola vez.” En un TabView, las pestañas no se destruyen ni se reconstruyen al cambiar entre ellas. SwiftUI las mantiene todas vivas para que el cambio sea instantáneo. Así que ese .task corre una única vez durante toda la vida de la app, y el filtro se siembra exactamente una vez - en el primer arranque.
Lo que significa: abrir Configuración, cambiar el valor por defecto de Discover, volver a Discover. El view model se construyó hace rato. Nunca vuelve a leer la configuración. Nada cambia. Lo único que sí aplicaría el nuevo valor por defecto es cerrar la app por completo y volver a abrirla.
Así que la demo es: cambiar una configuración, ver que no pasa nada, y la única forma de que “funcione” es forzar el cierre. Eso no es un error en el sentido de un crash o una salida incorrecta - el código hace exactamente lo que dice que hace. Pero es absolutamente un error en el sentido que importa, que es la persona que lo usa.
La solución fue eliminar la idea, no parchearla
Mi primer instinto fue hacer que la pestaña volviera a leer la configuración - al aparecer, al cerrar una hoja modal, en algún punto. Pero cada versión de eso se contradecía a sí misma. ¿Resembrar cuando la pestaña reaparece? Entonces cambiar de pestaña borra el filtro que se definió dentro de la pestaña. ¿Resembrar solo cuando cambió? Ahora hay que rastrear tokens de cambio. Cada parche agregaba un caso especial, que suele ser la señal de que el modelo está mal.
El modelo estaba mal. Había dos conceptos - un “default” guardado en Configuración y un “filtro actual” que vivía en la pestaña - y yo estaba constantemente tratando de sincronizarlos. Así que eliminé uno de los dos. Ahora solo existe el filtro, y vive en un único almacén compartido y persistente:
@MainActor @Observable
final class DiscoverViewModel {
private let settings: SettingsStore // shared, injected
var selectedTypes: Set<RecordingType> {
get { settings.filterTypes }
set { settings.filterTypes = newValue }
}
}
Los chips de filtro de la pestaña y la pantalla de Configuración ahora se enlazan a lo mismo. Se cambia en cualquier lado, y cambia en todos lados, en vivo, y se recuerda entre aperturas. Ya no hay un “default versus actual” porque ya no hay dos de nada. Toda esa clase de errores por datos desactualizados simplemente se evaporó, porque no quedó nada que mantener sincronizado.
También resultó ser el comportamiento que la gente realmente espera. “Recordar el filtro que elegí” es lo que hace casi cualquier app. El modelo de “reiniciar a un valor por defecto en cada sesión” que había construido por accidente ni siquiera es deseable - era solo un artefacto de sembrar el estado en la construcción.
La parte que quiero recordar
Dos cosas se me quedaron grabadas. Primero, la específica de SwiftUI: un view model guardado en el @State de una pestaña sobrevive a cada cambio de pestaña, así que cualquier cosa que se siembre en él al crearlo queda congelada hasta que la app se reabre. Si un valor necesita seguir una fuente que puede cambiar, no conviene copiarlo - hay que leerlo desde la fuente compartida.
Segundo, la más grande: pasar las pruebas y tener un diff limpio no decían nada sobre si la funcionalidad era buena. Hizo falta imaginar la secuencia real de toques - cambiar la configuración, no ver nada, forzar el cierre - para sacar a la luz un problema que el código nunca podría revelar por sí solo. La revisión más valiosa de esta funcionalidad no tuvo nada que ver con el código. Fue imaginar a una persona usándola, y notar que esa persona sentiría que le mintieron.
Lecturas relacionadas
Cuando el mismo ícono significa dos cosas distintas, y por qué la solución no fue solo cambiar una etiqueta
Dos botones de pulgar hacia abajo, con el mismo estilo rojo: uno permanente, uno inofensivo. El lenguaje visual hacía una promesa que el código no cumplía, y el nombre del método era parte del bug.
Unificando "Dismissed" vs "Not Interested," y la fila de Settings que repetía su propio encabezado de sección
Cuatro nombres para un mismo concepto, y el descubrimiento de que la solución obvia habría introducido una nueva inconsistencia un nivel más arriba, más la única repetición que es una función de seguridad, no un error.
Un reporte de bug de filtro que se convirtió en tres issues separados
El filtro reportado funcionaba perfectamente: la pantalla en cuestión era un filtro distinto, deliberadamente independiente, y la solución correcta fue una tercera opción completamente distinta.