Saltar al contenido

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:

TypeScript
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.

TypeScript
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

TSX
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:

TSX
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.