wandres.dev
NIVEL DIOS · Síntesis de la reactividad fina

Parte 4: el lote público y la política de vaciado

Convertimos la cola interna en una transacción reentrante de API pública, añadimos la protección contra cascadas infinitas y parametrizamos el vaciado para que el motor sirva en modo síncrono y asíncrono.

⏱ 17 min

La cola apareció en la parte 2 por necesidad de corrección. Ahora la terminamos: la exponemos como transacción pública para que varias escrituras produzcan una sola propagación, le añadimos la protección contra cascadas que no convergen, y la parametrizamos para que el mismo motor funcione en modo síncrono o alineado con el ciclo de un anfitrión.

🎯 Al terminar esta lección sabrás
  • Exponer el lote como API pública reentrante.
  • Verificar la atomicidad frente a estados intermedios observables.
  • Añadir la protección contra cascadas infinitas.
  • Parametrizar la política de vaciado sin duplicar código.

El lote como API pública

La función ya está escrita desde la parte 2; solo hay que exportarla y entender bien sus tres propiedades.

function lote(fn) {
  if (Lote) return fn();                    // reentrante
  const cola = Lote = new Set();
  try { return fn(); }
  finally {
    for (const e of cola) {
      cola.delete(e);                        // permite reencolarse mas tarde
      if (e.estado !== LIMPIO) actualizar(e);
    }
    Lote = null;
  }
}

Reentrante. Anidar lotes es seguro: la llamada interna se suma al abierto y solo el externo vacía. Eso permite que una función que agrupa internamente se llame desde dentro de otro agrupamiento sin comportamientos raros.

Vacía en el finally. Si el cuerpo lanza, los efectos pendientes se ejecutan igualmente. La alternativa —descartarlos— dejaría el grafo con nodos marcados que nadie va a resolver.

Usa un conjunto. Deduplica, y como for...of sobre un Set recorre también lo añadido durante la iteración, las cascadas se procesan en el mismo vaciado.

Con eso, varias escrituras producen una sola propagación.

const a = senal(1), b = senal(2);
let n = 0;
efecto(() => { a(); b(); n++; });     // n = 1
lote(() => { a.set(10); b.set(20); });
console.assert(n === 2);              // una sola reejecucion, no dos

La atomicidad que devuelve

El lote no solo ahorra trabajo: elimina estados que tu dominio considera imposibles. Con señales atómicas, un cambio conceptual que toque tres señales pasa por dos estados intermedios entre la primera escritura y la última.

const pagina = senal(1), desplazamiento = senal(0), seleccion = senal([]);

// Sin lote: el efecto ve tres combinaciones, dos de ellas incoherentes
function irAPagina(n) {
  pagina.set(n);
  desplazamiento.set(n * 20);
  seleccion.set([]);
}

// Con lote: una sola combinacion, la final
function irAPagina(n) {
  lote(() => {
    pagina.set(n);
    desplazamiento.set(n * 20);
    seleccion.set([]);
  });
}

Es la conclusión del nivel 8: repartir el estado introduce la posibilidad de estados intermedios que un estado único no tendría, y el lote es lo que devuelve la atomicidad perdida.

La protección contra cascadas

Un efecto que escribe una fuente de la que depende produce un vaciado que no termina. La defensa es un contador con un umbral generoso y, sobre todo, un mensaje que diga qué buscar.

function lote(fn) {
  if (Lote) return fn();
  const cola = Lote = new Set();
  try { return fn(); }
  finally {
    let vueltas = 0;
    for (const e of cola) {
      if (++vueltas > 10000) {
        cola.clear();
        Lote = null;
        throw new Error('bucle infinito: un efecto escribe una fuente que lee');
      }
      cola.delete(e);
      if (e.estado !== LIMPIO) actualizar(e);
    }
    Lote = null;
  }
}

El umbral tiene que ser alto porque una cascada legítima puede ser larga. Y el mensaje importa más que el número: el culpable casi nunca es el efecto que aparece en la traza, sino un par de efectos que se empujan mutuamente, y saber qué buscar ahorra horas.

flowchart TB
L[abrir lote] --> W[varias escrituras]
W --> M[marcar sin ejecutar]
M --> C[cola de efectos]
L --> F[cerrar lote]
F --> V[vaciar la cola]
V --> E[ejecutar efecto]
E --> S{el efecto escribe}
S -->|si| C
S -->|no| N{queda algo}
N -->|si| V
N -->|no| T[transaccion terminada]
style L fill:#89b4fa,color:#11111b
style W fill:#89b4fa,color:#11111b
style M fill:#f9e2af,color:#11111b
style C fill:#fab387,color:#11111b
style F fill:#89b4fa,color:#11111b
style V fill:#cba6f7,color:#11111b
style E fill:#a6e3a1,color:#11111b
style S fill:#f9e2af,color:#11111b
style N fill:#f9e2af,color:#11111b
style T fill:#a6e3a1,color:#11111b

La política de vaciado y las pruebas

Parametrizar el vaciado

Nuestro motor es síncrono: el lote vacía al cerrar. Para que sirva también dentro de un sistema con su propio ciclo, basta con extraer quién llama al vaciado.

let vaciador = (vaciar) => vaciar();                  // sincrono, por defecto

function usarVaciadoAsincrono() {
  let programado = false;
  vaciador = (vaciar) => {
    if (programado) return;
    programado = true;
    queueMicrotask(() => { programado = false; vaciar(); });
  };
}

Con el modo asíncrono hay que mantener una cola global única en lugar de una por lote, porque Lote se pone a null antes de que el vaciado ocurra y las escrituras intermedias abrirían su propia cola. Es una modificación de pocas líneas y merece hacerse si el motor va a vivir dentro de un anfitrión.

La decisión, como argumentaba el nivel 8, es quién lleva el reloj: si tu motor es la pieza central, síncrono; si vive dentro de algo que ya tiene su ritmo, alineado con él.

Pruébalo

// 1. Agrupamiento
const a = senal(1), b = senal(2);
let n = 0;
efecto(() => { a(); b(); n++; });
lote(() => { a.set(10); b.set(20); });
console.assert(n === 2);

// 2. Reentrancia: anidar no vacia dos veces
let m = 0;
const c = senal(0);
efecto(() => { c(); m++; });
lote(() => { c.set(1); lote(() => c.set(2)); c.set(3); });
console.assert(m === 2);

// 3. Cascada: un efecto que escribe propaga en el mismo vaciado
const x = senal(0), y = senal(0);
const log = [];
efecto(() => { log.push('x=' + x()); if (x() < 3) y.set(x()); });
efecto(() => log.push('y=' + y()));
x.set(1);
console.assert(log.join() === 'x=0,y=0,x=1,y=1');

La tercera es la que verifica que la cola recoge lo añadido durante el vaciado. Si el bucle no lo hiciera, el segundo efecto quedaría marcado sin resolver hasta la siguiente escritura, y el sistema terminaría el ciclo con nodos sucios, es decir, con la invariante rota.

El motor esta completo, y lo que falta es politica y no mecanismo

Con esta parte terminada, el motor está funcionalmente completo: propaga sin glitches, no hace trabajo inútil, libera sus recursos y agrupa transacciones. Todo lo que se le puede añadir a partir de aquí es política, no mecanismo. Prioridades entre efectos, alineación con el pintado, cancelación de trabajo en vuelo, integración con un ciclo de renderizado: todo eso son decisiones sobre cuándo se vacía la cola y en qué orden, y ninguna toca el algoritmo de propagación ni la estructura del grafo. Ese es un resultado con implicaciones prácticas. Primera: la parte difícil de un motor reactivo son las partes 1 y 2, y son las más cortas; el resto es acomodación al entorno. Segunda: cuando compares motores reales, casi todas las diferencias que percibas estarán en la política, y de ahí que el nivel 10 encontrara los cinco tan parecidos en el núcleo y tan distintos en la superficie. Y tercera, la que da sentido a la propuesta de Signals de TC39: si el mecanismo es común y solo difiere la política, tiene todo el sentido estandarizar el mecanismo y dejar la política fuera de la especificación. Que es exactamente lo que hace la propuesta, y de lo que trata la lección siguiente.

⚔️ Cambia la politica sin tocar el mecanismo
  1. Implementa la parametrización del vaciado y prueba tu motor en los dos modos.
  2. Convierte la cola en global para que el modo asíncrono funcione correctamente.
  3. Añade una cola de prioridad ordenada por profundidad en el árbol de dueños y comprueba que el orden entre efectos hermanos cambia.
  4. Verifica que ninguna de las tres modificaciones ha requerido tocar marcar ni actualizar.