La propagación: stale, push-then-pull y memos perezosos
Qué ocurre exactamente al escribir un signal: los tres estados de un nodo (limpio, STALE, PENDING), la fase push que solo marca sin calcular, la fase pull que recomputa bajo demanda, y por qué un memo pendiente se salta el recálculo cuando sus fuentes no cambiaron de verdad.
Un principiante imagina que setCount(5) recorre el grafo recalculando todo aguas abajo, en cascada, ahí mismo. No es así, y entender por qué no lo es explica casi toda la eficiencia de Solid. Escribir un signal no calcula nada: solo pinta de sucio a quien depende de él. El cálculo real llega después, en una segunda fase, y solo para los nodos que de verdad hacen falta. A este esquema en dos tiempos —marcar barato primero, computar caro después— se le llama push-then-pull, y es el algoritmo que comparten hoy casi todos los sistemas de signals serios.
- Conocer los tres estados de una computación: limpio,
STALEyPENDING. - Ver la fase push: marcar observadores sin ejecutar una sola línea de usuario.
- Ver la fase pull: recomputar en orden y solo lo necesario, al vaciar la cola.
- Entender la pereza a nivel de valor: un
PENDINGque no cambia no recomputa.
Tres estados: limpio, sucio y pendiente
Cada Computation lleva un campo state con tres valores posibles. No son dos —vivo o muerto— sino tres, y ese tercer estado es la clave de todo:
Limpio (0)
El valor del nodo está al día. Nadie lo ha invalidado. Leerlo es gratis: devuelve lo cacheado sin recomputar.
STALE (1)
Sucio con certeza. Una fuente directa cambió de valor, así que este nodo tiene que recomputarse antes de poder confiar en él.
PENDING (2)
Quizá sucio. Algún ancestro más arriba podría cambiar, pero aún no se sabe si el cambio llegará hasta aquí. Hay que averiguarlo mirando hacia arriba.
El estado STALE es rojo: reconstruye seguro. El estado PENDING es amarillo: comprueba antes de decidir. Esta distinción entre “sucio de verdad” y “sucio por precaución” es lo que permite a Solid no recalcular de más. Un nodo nace limpio, la escritura de un signal lo tiñe, y la recomputación lo devuelve al verde.
Push: marcar sin calcular
Cuando el setter detecta que el valor cambió de verdad —según el comparator—, recorre sus observers y los marca, sin ejecutar el cuerpo de ninguno. Los observadores directos pasan a STALE; los descendientes indirectos, a PENDING de forma recursiva vía markDownstream:
function markObservers(node: SignalState<any>) {
for (const o of node.observers ?? []) {
if (o.state === 0) { // solo si estaba limpio
o.pure ? Updates!.push(o) : Effects!.push(o);
if ((o as Memo<any>).observers) markDownstream(o as Memo<any>);
}
o.state = 1; // observador directo -> STALE
}
}
function markDownstream(node: Memo<any>) {
for (const o of node.observers!) {
if (o.state === 0) { // aun limpio
o.state = 2; // indirecto -> PENDING
o.pure ? Updates!.push(o) : Effects!.push(o);
(o as Memo<any>).observers && markDownstream(o as Memo<any>);
}
}
}
Observa dos cosas. Primero, el marcado es barato: son asignaciones de un entero y empujes a una cola, cero código de usuario. Segundo, hay dos colas: los memos (pure) van a Updates, los efectos van a Effects. Esa separación será decisiva en la lección 3 para que el DOM no parpadee. La onda de marcado no calcula: solo colorea el subgrafo afectado y agenda a los interesados.
Y un matiz que precede a todo esto: el marcado ni siquiera empieza si el valor no cambió. Antes de tocar a nadie, el setter compara el valor entrante con el actual usando el comparator del signal —por defecto, la identidad ===— y, si son iguales, retorna sin marcar, sin encolar y sin propagar. setN(5) sobre un signal que ya vale 5 es un no-op absoluto: la onda no nace. La primera línea de defensa contra el trabajo inútil está en el origen mismo, no aguas abajo.
flowchart LR S[signal cambia] -->|STALE| B[memo B] S -->|STALE| C[memo C] B -->|PENDING| D[memo D] C -.PENDING ya marcado.-> D D -->|PENDING| E[effect] style S fill:#89b4fa,color:#11111b style B fill:#f38ba8,color:#11111b style C fill:#f38ba8,color:#11111b style D fill:#fab387,color:#11111b style E fill:#fab387,color:#11111b
Pull: recomputar bajo demanda
Marcar no es actualizar. La segunda fase ocurre cuando se vacía la cola —al cerrar el lote de escrituras, el famoso batch—. Ahí sí corren los cuerpos, y en el orden correcto. Un nodo STALE se recomputa sin más. Un nodo PENDING, en cambio, no se fía: mira hacia arriba con lookUpstream para decidir si de verdad tiene que trabajar.
function lookUpstream(node: Computation<any>) {
node.state = 0; // optimista: me declaro limpio
for (const source of node.sources ?? []) {
const s = source as Memo<any>;
if (s.sources) { // la fuente es una computacion
if (s.state === 1) runUpdate(s); // STALE arriba: actualizala ya
else if (s.state === 2) lookUpstream(s); // PENDING arriba: sigue subiendo
}
}
}
Aquí está la pereza. Si al resolver sus ancestros ninguno cambió de valor, el nodo PENDING se queda limpio y no recomputa nada. Solo si un ancestro, al recomputarse, produce un valor distinto, ese ancestro vuelve a marcar a este nodo —esta vez como STALE—, y entonces sí se recalcula. El paso de PENDING a STALE no lo dispara el marcado inicial: lo dispara un cambio real de valor detectado durante el pull.
Fíjate en la primera línea, el node.state = 0 optimista: el nodo se declara limpio antes de mirar hacia arriba. Es una apuesta razonada. Si al subir encuentra un ancestro que cambia, ese ancestro volverá a ensuciarlo; si no encuentra ninguno, ya está en verde y no hay nada que deshacer. En un grafo real la apuesta casi siempre gana, porque la mayoría de las ondas de PENDING mueren sin producir un solo cambio de valor: son avisos de “por si acaso” que la comprobación hacia arriba desactiva sin gastar un cálculo.
Conviene precisar qué clase de pereza es esta. En el núcleo clásico (Solid 1.x), un memo con dependencias que cambian se recomputa al vaciar la cola aunque nadie lo lea: es eager respecto a la lectura. La pereza clásica es a nivel de valor —un PENDING cuyas fuentes no cambiaron se salta el cálculo—, no a nivel de lectura. El núcleo de Solid 2.0 lleva la pereza más lejos, hacia un modelo pull más puro donde un memo tiende a no computar hasta que alguien lo necesita. El contrato observable no cambia; lo que cambia es cuándo, exactamente, se paga el cálculo.
Un ejemplo que lo junta todo
const [n, setN] = createSignal(2);
const par = createMemo(() => n() % 2 === 0); // PENDING/STALE segun n
const etiqueta = createMemo(() => par() ? "par" : "impar");
createEffect(() => console.log(etiqueta()));
// setN(4): n cambia -> par STALE, etiqueta PENDING, effect PENDING
// pull: par recomputa -> sigue true -> NO notifica
// etiqueta sigue PENDING, lookUpstream no halla cambios -> NO recomputa
// el effect tampoco corre. Cero trabajo aguas abajo.
// setN(3): n cambia -> par STALE ...
// pull: par recomputa -> false, CAMBIO -> marca etiqueta STALE
// etiqueta recomputa -> "impar", CAMBIO -> effect corre una vez.
De 2 a 4 el par sigue siendo verdadero: la onda muere en el primer memo y ni la etiqueta ni el efecto se enteran. De 2 a 3 sí cambia la paridad, y la onda llega hasta el efecto —pero cada nodo corre exactamente una vez—. Ese “una vez, y solo si hace falta” es el resultado conjunto del push que marca y del pull que verifica.
¿Y si escribes varias veces seguidas? Dentro de un mismo manejador de evento —o de un batch explícito— las escrituras se acumulan y disparan una sola onda con el estado final:
batch(() => {
setN(4); // se acumula, no propaga todavia
setN(6); // se acumula
setN(7); // valor final del lote: 7
});
// una sola propagacion con n = 7: par recomputa una vez (impar -> cambio),
// etiqueta una vez ("impar"), effect una vez. No tres ondas: una.
El batch convierte tres escrituras en una única convergencia. Sin él, cada setN cerraría su propio ciclo push-pull y el efecto podría correr hasta tres veces; con él, el grafo solo ve el salto neto de 2 a 7. Es la misma agrupación que hace, por defecto, cualquier manejador de eventos de Solid.
Para razonar sobre propagación, piensa en colores. Verde (limpio): al día, no hará nada. Rojo (STALE): se recomputará seguro en el próximo flush. Amarillo (PENDING): se recomputará solo si, al mirar hacia arriba, alguna fuente cambió de valor. Cuando veas una recomputación que no esperabas, no busques un exceso de lecturas —leer jamás marca—: busca una fuente que cambia más a menudo de lo que creías, o un equals que devuelve false cuando debería devolver true. Toda onda nace de una escritura que pasó el filtro del comparador; sigue la onda hacia atrás y darás con el culpable.
La genialidad del push-then-pull es que descompone la propagación en una parte barata y una cara, y no paga la cara si puede evitarlo. El push responde a la pregunta barata —¿a quién podría afectar esta escritura?— con simples asignaciones de estado sobre el subgrafo alcanzable. El pull responde a la pregunta cara —¿a quién afectó de verdad?— pero solo cuando ya no hay más remedio, al vaciar la cola, y cortándose en seco en cuanto un valor no cambia. Compara esto con el modelo de re-render de React: allí, un cambio de estado marca un componente entero como sucio y vuelve a ejecutar toda su función para luego comparar el árbol resultante y descubrir qué cambió; el trabajo se hace primero y se descarta después. Solid invierte el orden: primero decide con precisión quirúrgica qué recalcular, y solo entonces recalcula. El estado PENDING es el héroe silencioso de esta historia —el “espera y verás” que evita reconstruir un memo caro cada vez que tiembla una hoja lejana del grafo—. Sin él tendrías propagación correcta pero derrochadora; con él, tienes la propagación mínima. Y esa minimalidad no es una optimización opcional que puedas olvidar activar: está cosida al algoritmo.
- Monta la cadena
n -> par -> etiqueta -> effectdel ejemplo y añade unconsole.logdentro de cada memo. Cuenta las recomputaciones al pasarnde 2 a 4 y de 2 a 3. - Explica, estado por estado, por qué
etiquetaqueda enPENDINGy no enSTALEtrassetN(4). - Añade un segundo efecto que lea
par()directamente. Comprueba que al cambiar la paridad ese efecto corre, pero al mantenerla no. - Razona qué colas —
UpdatesoEffects— reciben cada nodo y por qué los memos se drenan antes que los efectos. - Predice el número exacto de ejecuciones de cada nodo para la secuencia
setN(4); setN(6); setN(7)dentro de un mismo batch.