Glitch-free: por qué Solid nunca muestra estados intermedios
El problema del diamante y los glitches: valores derivados calculados con una mezcla de entradas viejas y nuevas. Cómo el marcado PENDING, el orden topologico del pull, el contador de ejecucion y la separacion entre cola de memos y cola de efectos garantizan que cada nodo se calcule una vez, con entradas consistentes, y que el DOM solo vea el estado final.
El problema del diamante
Imagina un signal a del que derivan dos memos, b y c, y un tercer memo d que depende de los dos. El grafo tiene forma de rombo:
En código, ese rombo es tan corriente como esto —y aparece sin que lo busques en cuanto dos derivaciones comparten un origen—:
const [a, setA] = createSignal(1);const b = createMemo(() => a() * 2);const c = createMemo(() => a() + 10);const d = createMemo(() => b() + c()); // depende de a por DOS caminosSupón d = b + c. Al cambiar a, tanto b como c deben recomputarse. Un sistema ingenuo que propague en cascada haría esto: cambia a, recomputo b, y como b cambió, recomputo d ya mismo… pero c todavía tiene su valor viejo. Durante ese instante, d vale b_nuevo + c_viejo: un número que nunca debió existir. Luego se recomputa c, se vuelve a recomputar d, y ahora sí queda bien. El resultado final es correcto, pero por el camino d se calculó dos veces y una de ellas fue un glitch. Si un efecto leía d, pudo dispararse con basura.
Pongámosle números. Con a = 1, b = a * 2, c = a + 10 y d = b + c, el estado inicial es b = 2, c = 11, d = 13. Ahora ejecutas setA(5). La secuencia ingenua sería esta:
// setA(5): cascada ingenua, paso a paso// b recomputa -> 10 (nuevo)// d recomputa YA -> 10 + 11 = 21 <- GLITCH: mezcla b nuevo con c viejo// c recomputa -> 15 (nuevo)// d recomputa OTRA vez -> 10 + 15 = 25 <- por fin, el valor correctoEl 21 es el fantasma: un valor que el usuario podría llegar a ver pintado durante una fracción de milisegundo, y que además costó un cálculo entero de d para nada. Y los glitches no son solo un problema estético. Si en lugar de pintar un número d alimentara un efecto que lanza una petición al servidor o registra un evento de analítica, el cálculo fantasma dispararía una acción de más con datos incoherentes. La consistencia del grafo no va de apariencia: va de no ejecutar efectos secundarios sobre estados que nunca debieron existir. En un grafo con miles de nodos y diamantes anidados, este desperdicio se multiplica.
La cura: PENDING como sala de espera
Solid nunca cae en la cascada inmediata. Como viste en la lección 2, al cambiar a no se recomputa nada: se marca. Los observadores directos b y c quedan STALE; el nodo d, alcanzado por dos caminos, queda PENDING una sola vez —el segundo camino lo encuentra ya marcado y no lo vuelve a encolar—. Solo entonces empieza el pull, y aquí actúa la sala de espera:
// d esta PENDING. Al procesarlo, lookUpstream resuelve sus fuentes primero:function resolverD(d: Memo<any>) { for (const fuente of d.sources!) { // b y c if (fuente.state === 1) runUpdate(fuente); // recomputa la que este STALE else if (fuente.state === 2) lookUpstream(fuente); } // Solo despues de que b Y c esten al dia, d decide si recomputa. if (d.state === 1) runUpdate(d); // recomputa una sola vez, con ambas frescas}La clave: d no se computa mientras alguna de sus fuentes siga sucia. El estado PENDING lo mantiene en la sala de espera hasta que b y c estén ambas resueltas. Cuando por fin corre, lee b_nuevo y c_nuevo a la vez. El valor b_nuevo + c_viejo no llega a materializarse jamás. Y d corre una sola vez, no dos.
Con la sala de espera, la misma escritura setA(5) produce esta traza —los mismos números de antes, sin fantasma—:
// setA(5): con push-then-pull// PUSH: a marca b y c como STALE; d, alcanzado por dos caminos, queda PENDING una vez// PULL: se procesa b -> 10 (cambio) -> marca d de PENDING a STALE// se procesa c -> 15 (cambio) -> d ya estaba STALE, sigue STALE// se procesa d -> 10 + 15 = 25 <- UNA vez, con b y c ya frescasEl 21 no aparece en ninguna línea, porque d nunca se computa hasta que sus dos entradas están al día. Y donde antes había dos cálculos de d, ahora hay uno. Fíjate en el detalle fino de la lección 2: d pasa de PENDING a STALE en el instante exacto en que b, al recomputarse, produce un valor distinto. Si b hubiera devuelto el mismo número, d seguiría PENDING y, de no cambiar tampoco c, no recomputaría en absoluto. La sala de espera no solo elimina el glitch: elimina también todo el trabajo que no cambia nada.
Orden topológico sin ordenar el grafo
Para que d no se compute antes que b y c, hace falta respetar el orden topológico del grafo: todo nodo después de sus dependencias. Lo curioso es que Solid no ordena el grafo con un algoritmo de ordenación topológica explícito. El orden correcto emerge de dos mecanismos combinados:
Ese contador es el guardián contra el doble cálculo. En el diamante, cuando d resuelve b y luego c, si ambas rutas intentaran actualizar un ancestro común, el updatedAt ya marcado en este tick lo impide: se corre una vez por tick y no más. Entre la sala de espera del PENDING y el sello temporal del updatedAt, cada nodo del grafo se recomputa como mucho una vez por propagación, y siempre después de aquello de lo que depende. Eso es un orden topológico, obtenido sin ordenar nada: pagado con dos enteros por nodo.
Conviene entender por qué basta con esto y no hace falta un ordenamiento topológico global, del estilo del algoritmo de Kahn. Un orden precalculado obligaría a re-ordenar el grafo entero cada vez que cambia una dependencia —y en Solid las dependencias son dinámicas: cambian en cada ejecución según las ramas que se tomen, como viste en el nivel 4—. La resolución perezosa hacia arriba descubre el orden correcto sobre la marcha, solo para el subgrafo realmente afectado por la escritura y solo cuando hace falta computar. Es orden topológico bajo demanda, la única clase de orden que sobrevive a un grafo que muta en cada tick.
Los efectos, al final: el DOM no parpadea
Queda la última pieza, la que protege lo que el usuario ve. Recuerda las dos colas de la lección 2: los memos (pure) van a Updates, los efectos van a Effects. Al cerrar el batch, Solid drena primero toda la cola de memos y solo después la de efectos:
function completeUpdates() { runQueue(Updates); // 1) todos los memos se asientan: el grafo puro converge const e = Effects; Effects = null; runQueue(e); // 2) recien ahora corren efectos: DOM, red, logs}Un efecto —que toca el DOM, lanza una petición o escribe un log— nunca corre en mitad de la convergencia. Corre cuando todos los memos ya alcanzaron su valor final. Por eso el DOM salta directo del estado viejo al nuevo, sin fotogramas intermedios: cuando el efecto de render se ejecuta, d ya es coherente. La separación de colas es lo que extiende la coherencia del grafo puro al mundo de los efectos secundarios.
Este drenaje ocurre de forma síncrona al cerrar el lote de escrituras. Varias escrituras dentro de un mismo manejador de evento —o envueltas en batch— se acumulan sin disparar nada, y al soltarlas se procesa una sola onda push-pull con todos los valores nuevos a la vez. Por eso no observas resultados a medias entre dos setX consecutivos del mismo manejador: el grafo no despierta hasta que sueltas el lote. Un diamante con sus dos ramas escritas en el mismo batch se resuelve exactamente igual que si una sola fuente hubiera cambiado: una convergencia, un repintado, cero fantasmas.
Hay un regalo práctico escondido en este orden. Cuando un efecto de layout mide el DOM —lee un offsetHeight, coloca un tooltip, ajusta un scroll—, lo hace siempre sobre el estado final, nunca sobre uno a medio construir, porque para cuando corre ya convergió todo el grafo puro. La misma coreografía que impide glitches entre memos es la que te deja medir el DOM con la certeza de que lo que lees es lo definitivo.