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.
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:
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.
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: actualízala 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.
Un ejemplo que lo junta todo
const [n, setN] = createSignal(2);const par = createMemo(() => n() % 2 === 0); // PENDING/STALE segun nconst 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.