Las fugas de listeners
Registrar es fácil y desregistrar se olvida. Cómo se acumulan, por qué el síntoma es que algo pasa dos veces en vez de un consumo raro, y los patrones que lo cierran en cada framework.
Cómo se acumulan
// El componente se monta, registra, y no guarda nada.async function montar() { await listen('catalogo-actualizado', (e) => refrescar(e.payload));}Cada vez que ese componente se monta, hay un listener más. Y los componentes se montan más de lo que parece: al navegar y volver, al cambiar de pestaña, al re-renderizar en algunos frameworks, y en desarrollo con cada recarga en caliente.
El síntoma
No es el consumo de memoria, aunque también crezca. Lo que se ve primero es esto:
- Una notificación que aparece tres veces.
- Una petición que se hace cuatro veces seguidas.
- Una animación que se dispara dos veces y salta.
- Un contador que sube de dos en dos.
Y la característica que lo delata: empeora con el uso. La primera vez que abres la aplicación va bien; después de diez minutos navegando, todo pasa varias veces. Si alguien dice «se pone raro cuando llevas un rato», la primera hipótesis son los listeners.
En desarrollo el efecto se amplifica muchísimo por la recarga en caliente, lo cual es una suerte: se detecta antes. Un fallo que en producción tardaría una hora en notarse, en desarrollo aparece en cinco minutos.
El patrón de limpieza
La forma es siempre la misma —registrar al montar, desregistrar al desmontar— y lo que cambia es cómo lo expresa cada framework:
// vanilla, con una función de limpieza explícitaasync function montar() { const unlisten = await listen('catalogo-actualizado', (e) => refrescar(e.payload)); return () => unlisten(); // quien montó, desmonta}// ReactuseEffect(() => { let unlisten; listen('catalogo-actualizado', (e) => refrescar(e.payload)).then((f) => (unlisten = f)); return () => unlisten?.();}, []);// SolidonMount(async () => { const unlisten = await listen('catalogo-actualizado', (e) => refrescar(e.payload)); onCleanup(unlisten);});La trampa común a los tres: registrar es asíncrono, así que la función de limpieza puede ejecutarse antes de que el registro haya terminado. En ese caso hay que acordarse de desregistrar cuando llegue. Los ejemplos de React y Solid de arriba lo tratan a medias; una versión robusta guarda una bandera de cancelación:
let cancelado = false;let unlisten;listen(nombre, manejador).then((f) => (cancelado ? f() : (unlisten = f)));return () => { cancelado = true; unlisten?.(); };Los dos casos especiales
El recargado de la webview. Al recargar, el contexto de JavaScript desaparece entero y con él todos los listeners. Eso significa que un recargado limpia las fugas —de ahí que el síntoma se resetee— y también que hay que volver a registrar todo, que es lo que el nivel 20.3 resolvía con el orden de suscribirse y preguntar.
Un listener que debe vivir toda la sesión. Un aviso de actualización disponible, por ejemplo, no pertenece a ningún componente. Registrarlo en un componente es lo que produce el problema; el sitio correcto es el arranque de la aplicación, una sola vez, y sin desregistrar nunca. Lo importante es que sea una decisión escrita y no un olvido: un listener sin limpieza tiene que estar en un módulo cuyo nombre lo diga.