Memoria en SPAs: disponer y no acumular
En una SPA la página no se recarga nunca, así que efectos, listeners y raíces sin dispose se apilan hasta ahogar la pestaña. Las fugas típicas, la disciplina de disponer al cambiar de vista, y cómo depurar: el aviso de computaciones huérfanas, el grafo de owners en las DevTools y los snapshots de memoria del navegador.
Las fugas típicas
Casi todas las fugas de Solid en una SPA caben en tres moldes, y los tres comparten causa: una computación o un recurso que sobrevive al propósito que lo justificaba.
El contraste entre la versión que fuga y la que no cabe en unas líneas. El pecado es abrir sin cerrar; la penitencia es coser el cierre al ciclo de vida.
import { createRoot, onCleanup } from "solid-js";// FUGA: cada visita abre una raiz que nadie dispone.function alEntrarEnVista() { createRoot(() => { /* efectos, sondeos, suscripciones... vivos para siempre */ });}// CORRECTO: la raiz muere con el owner de la vista.function alEntrarEnVista_ok() { const dispose = createRoot((d) => { /* ...misma reactividad... */ return d; }); onCleanup(dispose); // atada al desmontaje de la vista}Observa que la distancia entre fuga y corrección no es el volumen de código —son idénticos salvo por dos detalles: declarar el parámetro d para obtener el dispose, y coserlo con onCleanup—. Las fugas de Solid casi nunca son aparatosas; son una línea que falta. Por eso se cuelan en revisión: el código que fuga funciona, se ve bien y pasa los tests de comportamiento. Solo delata su defecto con el tiempo y el uso, que es justo cuando ya nadie mira el diff.
Disponer al cambiar de vista
En una SPA, la unidad de vida es la vista, no la pestaña. Un router bien construido crea un owner por ruta y lo dispone al navegar fuera: por eso los efectos y limpiezas dentro de un componente de página se recogen solos al cambiar de sección. La disciplina consiste en asegurarte de que todo lo reactivo cuelgue de ese owner de ruta, de modo que herede su muerte.
Lo que escapa al owner de la ruta es lo que debes coser tú. Una suscripción externa se ata envolviéndola en un efecto con su limpieza; así el propio ciclo de la vista la abre y la cierra sin que tengas que recordar el desmontaje.
import { createEffect, onCleanup } from "solid-js";function usarPrecio(simbolo: () => string) { createEffect(() => { const socket = abrirCanal(simbolo()); // recurso externo socket.onMensaje = (p) => setPrecio(p); onCleanup(() => socket.cerrar()); // se cierra al cambiar simbolo }); // y al desmontar la vista}Cuando simbolo cambia, Solid corre la limpieza del run anterior antes del nuevo: nunca hay dos canales abiertos. Cuando la vista se desmonta, corre la última limpieza pendiente. La suscripción vive exactamente lo que vive la correspondencia que la justifica, ni un milisegundo más.
La regla que se destila de aquí es una sola: no confíes en tu memoria, confía en el owner. En vez de recordar «cuando salga de esta vista, cierra el socket», haces que el socket cuelgue del owner de la vista y dejas que su muerte —ya garantizada por el router— arrastre la limpieza. Convertir cada recurso externo en un habitante del árbol de ownership traslada la responsabilidad de tu disciplina, que es falible, a la estructura del programa, que es determinista. Ese traslado es, en una frase, toda la gestión de memoria en Solid.
La excepción son los efectos que deben trascender la vista: un sondeo de notificaciones, un reloj global, la sincronización de la sesión. Esos no van en el owner de una ruta —morirían al navegar— sino en la raíz disponible del nivel anterior, o en la raíz de la app. La pregunta, de nuevo, no es «¿lo dispongo?» sino «¿de qué owner debe colgar para vivir lo justo?». Unos cuelgan de la vista y mueren al salir; otros, de la app y mueren con ella. Acertar ese emparejamiento es diseñar la topología de vida de tu SPA.
Depurar una fuga
Cuando la memoria sube y no sabes por qué, tienes una escalera de instrumentos, de más barato a más caro.
- El aviso de consola. Computations created outside a
createRootorrenderwill never be disposed es tu primera línea de defensa. Nunca lo silencies: señala exactamente una computación sin owner, es decir, una fuga garantizada. Arréglala envolviéndola en una raíz o moviéndola dentro de un owner. - El grafo de owners. Las DevTools de Solid muestran el árbol de owners en vivo. Navega por tu app y observa si, al salir de una vista, su subárbol desaparece. Si un subárbol persiste tras abandonar la ruta, ahí está la raíz que no se dispone.
- Los snapshots de memoria. En las DevTools del navegador, toma un heap snapshot, ejercita el flujo sospechoso varias veces, toma otro y compara. Cuenta de nodos del DOM detached, de listeners o de instancias de tus clases que crezcan monótonamente delatan la acumulación. La grabación de allocation timeline señala en qué acción nace lo que no muere.
import { getOwner } from "solid-js";// Sonda casera: cuenta los hijos directos del owner actual.function contarHijos() { const o = getOwner() as { owned?: unknown[] } | null; console.log("computaciones bajo este owner:", o?.owned?.length ?? 0);}La estrategia que ata los tres instrumentos es la bisección. Reproduce la fuga con el flujo mínimo que la provoca, divídelo en dos mitades, mira en cuál crece la memoria y repite sobre esa mitad. El grafo de owners te dice qué subárbol no muere; el snapshot te dice cuánto retiene y qué lo mantiene con vida; el aviso de consola, cuando aparece, te clava el dedo en la línea exacta. Casi ninguna fuga sobrevive a aplicar los tres en ese orden, del más barato al más caro. Y la mejor fuga es la que nunca ocurre: si cada createRoot que abres lleva pegada su respuesta a «¿cuándo muere esto?», la mayoría no llega siquiera a nacer.
Más allá de cazarlas, conviene prevenirlas con hábitos que las vuelvan raras. Trata cada createRoot fuera de un componente como código que debe terminar en un onCleanup(dispose) o en una llamada explícita a dispose, sin que ninguno de los dos falte en la revisión. Envuelve las suscripciones a librerías externas siempre en un createEffect con su onCleanup, nunca sueltas. Una sonda de desarrollo mínima ayuda a notar desmontajes anómalos:
import { onCleanup } from "solid-js";// Canario casero: avisa cuando una vista se limpia, y cuanto tardo.function vigilarDesmontaje(nombre: string) { const t0 = performance.now(); onCleanup(() => { console.debug(`${nombre} desmontada tras ${performance.now() - t0}ms`); });}Si esperabas ese aviso al salir de una vista y no llega, la vista no se está desmontando: ahí tienes la fuga, señalada antes de abrir un solo snapshot.