La cola de efectos
La estructura que decide qué se ejecuta y en qué orden: cómo se llena, cómo se vacía, qué pasa cuando se modifica durante el vaciado y por qué la cola forma parte de la garantía de corrección.
La cola de efectos parece un detalle de implementación del lote y es bastante más: es la estructura que separa la fase de marcado de la de ejecución, y por tanto es parte de la garantía glitch-free del nivel 5. Su diseño —qué estructura usar, cómo vaciarla, qué hacer con lo que se añade durante el vaciado— tiene consecuencias observables en el comportamiento del motor.
- Justificar la cola como pieza de la garantía de consistencia.
- Elegir la estructura de datos correcta y su criterio de orden.
- Manejar correctamente los efectos añadidos durante el vaciado.
- Implementar la protección contra cascadas infinitas.
La cola es parte de la corrección
Vuelve al nivel 5. La garantía glitch-free se apoyaba en un invariante: ningún cuerpo se ejecuta durante la fase de marcado. Si un efecto se ejecutara mientras se marca, leería nodos aún sin marcar y vería una mezcla de estados.
La cola es lo que hace cumplir ese invariante. Cuando el marcado encuentra un efecto, no lo ejecuta: lo encola. La ejecución ocurre después, cuando el marcado ha terminado.
function marcar(fuente, estado) {
for (const obs of fuente.observadores) {
if (obs.estado >= estado) continue;
const antes = obs.estado;
obs.estado = estado;
if (obs.efecto) planificar(obs); // ENCOLA, no ejecuta
if (antes === LIMPIO) marcar(obs, QUIZA);
}
}
Cambiar ese planificar por una ejecución directa reintroduce los glitches inmediatamente. Por eso la cola no es una optimización de agrupamiento que se pueda desactivar: es estructural.
Qué estructura usar
Tres opciones, con propiedades distintas.
Un array. Orden de encolado, deduplicación manual con una bandera por nodo. Es lo que usan varios motores porque es el más rápido de recorrer y permite índices.
Un conjunto. Deduplicación automática, orden de inserción, y la propiedad valiosísima de que iterar con for...of recorre también lo añadido durante la iteración. Es lo que usaremos.
Una cola de prioridad. Permite ordenar por un criterio: profundidad en el árbol de componentes, prioridad declarada, o cualquier otro. Es lo que hacen los motores que quieren garantizar que un efecto de padre corre antes que uno de hijo.
// Con conjunto: el vaciado recoge tambien lo anadido durante el vaciado
function vaciar(cola) {
for (const efecto of cola) {
cola.delete(efecto); // permite reencolarse mas tarde
if (efecto.estado !== LIMPIO) actualizar(efecto);
}
}
El delete antes de ejecutar es deliberado. Si el efecto vuelve a ensuciarse durante el mismo vaciado —porque otro efecto escribió algo que él lee—, se reencola al final y se vuelve a recorrer. Sin el delete, seguiría en el conjunto y no se podría distinguir de una entrada ya procesada.
Qué orden garantizar
El orden dentro de la cola es observable por el programador, y aquí hay una decisión real que tomar.
Orden de encolado. El más simple. Depende del orden en que el marcado recorrió las listas de observadores, que a su vez depende del orden en que se tejieron las aristas. Es determinista para una ejecución dada pero difícil de predecir leyendo el código.
Orden de creación. Los efectos se ejecutan en el orden en que se crearon. Como los efectos suelen crearse recorriendo el árbol de componentes de arriba abajo, esto produce el orden natural de padres antes que hijos.
Orden por profundidad en el árbol de dueños. Explícito y estable, con el mismo resultado que el anterior pero sin depender de cuándo se creó cada cosa.
Los motores reales tienden a las dos últimas, porque el orden entre efectos hermanos tiene consecuencias visibles: si un efecto mide el DOM y otro lo modifica, el orden decide si la medición es correcta.
Si dos efectos tienen que ocurrir en un orden concreto, no confíes en el orden de la cola: hazlo explícito en el grafo. La forma limpia es que el segundo dependa de algo que el primero produzca, con lo que el orden pasa a estar garantizado por la topología en vez de por la política de la cola. Depender del orden de la cola es depender de un detalle de implementación que puede cambiar entre versiones.
Reentrada y protección
Efectos añadidos durante el vaciado
Este es el caso interesante. Un efecto que se ejecuta puede escribir una señal, y esa escritura puede ensuciar otros efectos, que se añaden a la cola que se está vaciando.
El comportamiento correcto es procesarlos en el mismo vaciado. Si no, quedarían pendientes hasta la siguiente escritura, y el sistema terminaría el ciclo con nodos sucios sin resolver, es decir, con la invariante rota.
const a = senal(0), b = senal(0);
efecto(() => { if (a() < 3) b.set(a()); }); // efecto que escribe
efecto(() => registrar('b vale ' + b()));
a.set(1);
// vaciado: corre el primero, escribe b, encola el segundo, corre el segundo
El conjunto lo hace de forma natural. Con un array habría que usar un índice en lugar de un for...of para recoger lo añadido, y con una cola de prioridad hay que insertar respetando el orden.
flowchart TB
M[marcado] --> E[efectos encolados]
E --> V[vaciado]
V --> X[ejecutar efecto]
X --> W{el efecto escribe}
W -->|si| N[se encolan mas efectos]
N --> V
W -->|no| S{queda algo en la cola}
S -->|si| V
S -->|no| F[ciclo terminado]
style M fill:#89b4fa,color:#11111b
style E fill:#f9e2af,color:#11111b
style V fill:#cba6f7,color:#11111b
style X fill:#cba6f7,color:#11111b
style W fill:#f9e2af,color:#11111b
style N fill:#fab387,color:#11111b
style S fill:#f9e2af,color:#11111b
style F fill:#a6e3a1,color:#11111bLa protección contra cascadas infinitas
Un efecto que escribe una señal de la que depende produce un bucle infinito: se ejecuta, escribe, se ensucia, se reencola, se ejecuta. El vaciado no termina nunca y el hilo se cuelga.
La defensa estándar es un contador de iteraciones con un umbral.
function vaciar(cola) {
let vueltas = 0;
for (const efecto of cola) {
if (++vueltas > 10000) {
cola.clear();
throw new Error('bucle infinito de actualizaciones: un efecto escribe lo que lee');
}
cola.delete(efecto);
if (efecto.estado !== LIMPIO) actualizar(efecto);
}
}
El umbral es arbitrario y tiene que ser generoso, porque una cascada legítima puede ser larga. Lo importante no es el número sino que el mensaje diga qué buscar: un efecto que escribe una fuente de la que depende, directa o transitivamente.
Todos los motores tienen esta protección con umbrales parecidos y mensajes de error que apuntan a la misma causa. Es una de esas piezas que solo se aprecian cuando te salvan de un cuelgue en producción.
Merece la pena reconocer lo que es esta pieza: la cola de efectos es un planificador de tareas, con todo lo que eso implica. Tiene una política de admisión —qué se encola—, una política de orden —qué corre antes—, una política de reentrada —qué pasa con lo que llega durante la ejecución— y una política de protección —qué hacer cuando algo se descontrola. Y como todo planificador, esas políticas son donde vive el comportamiento observable del sistema, mucho más que en el algoritmo de propagación. Dos motores con exactamente el mismo grafo, la misma propagación y las mismas garantías de consistencia pueden comportarse de forma radicalmente distinta si sus políticas de cola difieren, y eso explica buena parte de las diferencias que percibirás entre frameworks en el nivel 10. Hay además una consecuencia práctica que ahorra mucha frustración: cuando algo pasa en un orden que no esperabas, la respuesta casi nunca está en el grafo sino en la cola. El grafo garantiza que las dependencias se resuelven antes que los dependientes, y no garantiza nada sobre el orden relativo de dos efectos que no dependen el uno del otro. Ese orden lo decide la política de la cola, y depender de él sin haberlo hecho explícito en el grafo es construir sobre un detalle de implementación que ningún motor te promete mantener.
- Implementa la cola con un conjunto y verifica que los efectos añadidos durante el vaciado se procesan.
- Cámbiala por una cola de prioridad ordenada por profundidad en el árbol de dueños.
- Escribe un caso con un efecto de padre y otro de hijo y comprueba que el orden cambia entre las dos implementaciones.
- Provoca una cascada infinita y verifica que la protección salta con un mensaje útil.