wandres.dev
EL ÁRBOL DE DUEÑOS · Ownership y disposal automático

Implementar el árbol de dueños completo

Las veinticinco líneas que añaden propiedad, limpieza en cascada, raíces y contexto heredado a un motor reactivo, con las pruebas que verifican cada garantía.

⏱ 17 min

Cerramos el nivel juntando todas las piezas en una implementación que funciona y que se puede probar. El árbol de dueños completo —registro, cascada, raíces, captura de dueño y contexto heredado— cabe en unas veinticinco líneas, y cada una de sus garantías se verifica con un test de cinco. Este código es literalmente el que aparecerá en el motor del nivel 12.

🎯 Al terminar esta lección sabrás
  • Ensamblar el árbol de dueños completo en un módulo coherente.
  • Añadir contexto heredado sobre la misma estructura.
  • Escribir las pruebas que verifican cada garantía.
  • Integrar el árbol con el ciclo de reejecución de computaciones.

El módulo completo

let Duenno = null;

function registrar(nodo) {
  if (Duenno) Duenno.hijos.push(nodo);
}

function alLimpiar(fn) {
  if (Duenno) Duenno.limpiezas.push(fn);
  else fn();                       // sin duenno, limpiar de inmediato es lo menos malo
}

function limpiar(nodo) {
  for (const hijo of nodo.hijos) desechar(hijo);
  nodo.hijos.length = 0;
  for (const fn of nodo.limpiezas) {
    try { fn(); } catch (e) { console.error('fallo en limpieza', e); }
  }
  nodo.limpiezas.length = 0;
}

function desechar(nodo) {
  limpiar(nodo);
  if (nodo.fuentes) desatar(nodo);
  nodo.estado = LIMPIO;
}

function raiz(fn) {
  const nodo = { hijos: [], limpiezas: [], fuentes: null, contexto: Duenno?.contexto ?? null };
  const dA = Duenno, oA = Observador;
  Duenno = nodo; Observador = null;
  try { return fn(() => desechar(nodo)); }
  finally { Duenno = dA; Observador = oA; }
}

function duennoActual() { return Duenno; }

function conDuenno(duenno, fn) {
  const dA = Duenno, oA = Observador;
  Duenno = duenno; Observador = null;
  try { return fn(); } finally { Duenno = dA; Observador = oA; }
}

Veinticinco líneas. El detalle de alLimpiar sin dueño merece un comentario: hay tres políticas posibles —ejecutar de inmediato, ignorar en silencio, o avisar— y la mejor para un motor didáctico es ejecutar, porque hace que la primitiva sea siempre segura de llamar. Solid, por ejemplo, avisa en desarrollo cuando se registra una limpieza fuera de un ámbito, que es la opción más informativa.

La integración con la reejecución

El árbol se engancha al ciclo de vida de las computaciones en dos puntos, y solo dos.

function crearComputacion(fn, esEfecto) {
  const nodo = {
    fn, valor: undefined, estado: SUCIO, efecto: esEfecto,
    fuentes: new Set(), observadores: new Set(),
    hijos: [], limpiezas: [],
  };
  registrar(nodo);                   // punto 1: al crearse, me posee el duenno actual
  return nodo;
}

function actualizar(nodo) {
  // ... resolucion del estado
  limpiar(nodo);                     // punto 2: al reejecutar, destruyo lo de la pasada anterior
  desatar(nodo);
  const oA = Observador, dA = Duenno;
  Observador = nodo; Duenno = nodo;  // soy observador y duenno a la vez
  try { nodo.valor = nodo.fn(nodo.valor); }
  finally { Observador = oA; Duenno = dA; nodo.estado = LIMPIO; }
}

Que sean solo dos puntos es lo que hace el mecanismo fiable. Cuantas menos funciones toquen el árbol, menos sitios hay donde romper sus invariantes.

Contexto heredado sobre la misma estructura

Como el árbol de dueños ya modela el anidamiento léxico, sirve tal cual para pasar valores hacia abajo sin parámetros.

function proveer(clave, valor, fn) {
  if (!Duenno) throw new Error('proveer necesita un duenno');
  Duenno.contexto = { ...(Duenno.contexto ?? {}), [clave]: valor };
  return fn();
}

function consumir(clave, porDefecto) {
  let d = Duenno;
  while (d) {
    if (d.contexto && clave in d.contexto) return d.contexto[clave];
    d = d.padre;
  }
  return porDefecto;
}

Para que consumir funcione hace falta un campo padre en cada nodo, que se asigna en registrar. Es una línea más y merece la pena porque abre esta funcionalidad entera.

function registrar(nodo) {
  nodo.padre = Duenno;
  if (Duenno) Duenno.hijos.push(nodo);
}

Con eso, el mismo árbol responde a las tres preguntas: quién me destruye, qué destruyo yo, y de quién heredo configuración. Es exactamente lo que hace el contexto en cualquier framework de esta familia.

flowchart TB
R[raiz] --> A[ambito de la pagina]
A --> B[ambito del panel]
B --> C[efecto que consume el tema]
A -.provee tema.-> A
C -.sube buscando el tema.-> A
style R fill:#cba6f7,color:#11111b
style A fill:#cba6f7,color:#11111b
style B fill:#cba6f7,color:#11111b
style C fill:#a6e3a1,color:#11111b

Las pruebas y el cierre del nivel

Las pruebas

Cada garantía tiene su test, y son cortos.

// 1. Cascada: los hijos se desechan antes que el padre
{
  const orden = [];
  const tirar = raiz((desechar) => {
    alLimpiar(() => orden.push('padre'));
    efecto(() => { alLimpiar(() => orden.push('hijo')); });
    return desechar;
  });
  tirar();
  console.assert(orden.join() === 'hijo,padre');
}

// 2. Limpieza entre reejecuciones
{
  const a = senal(0), log = [];
  raiz(() => efecto(() => { const v = a(); alLimpiar(() => log.push('limpio ' + v)); log.push('corre ' + v); }));
  a.set(1); a.set(2);
  console.assert(log.join() === 'corre 0,limpio 0,corre 1,limpio 1,corre 2');
}

// 3. Una raiz no rastrea lo de fuera
{
  const a = senal(0); let n = 0;
  efecto(() => { n++; raiz(() => a()); });     // la lectura dentro de la raiz no suscribe
  a.set(1);
  console.assert(n === 1);
}

// 4. Desechar desata las aristas
{
  const a = senal(0);
  const tirar = raiz((d) => { efecto(() => a()); return d; });
  console.assert(a.nodo.observadores.size === 1);
  tirar();
  console.assert(a.nodo.observadores.size === 0);
}

La cuarta es la más importante y la que más se olvida al implementar: desechar tiene que desatar. Sin esa línea, el árbol destruye la estructura de propiedad y deja las aristas del grafo intactas, con lo que la fuga que el mecanismo pretendía evitar sigue exactamente igual.

Un motor sin arbol de duennos no es un motor, es media libreria

Después de implementarlo, merece la pena valorar lo que este mecanismo aporta al conjunto. Sin él tienes un sistema de propagación correcto que no se puede usar en una aplicación real, porque cada efecto que crees es una fuga a menos que lleves la cuenta a mano, y llevar la cuenta a mano es el problema que demostramos que no escala en la lección anterior. Con él, la creación de efectos se vuelve una operación que no requiere pensar en el ciclo de vida, y eso cambia por completo el estilo de código que la gente escribe: puedes crear efectos dentro de bucles, dentro de condicionales, dentro de funciones auxiliares, con la tranquilidad de que morirán con su ámbito. Esa tranquilidad es la que permite la reactividad de grano fino de verdad, con miles de efectos pequeños en lugar de unos pocos grandes; sin ella, cada efecto tendría un coste administrativo que empujaría a agruparlos, y agruparlos es exactamente lo que destruye la granularidad que hacía valioso el modelo. De modo que hay una cadena de implicaciones que conviene tener presente y que muy poca gente hace explícita: el árbol de dueños es lo que hace viable el grano fino. Sin propiedad automática, el coste de gestionar mil efectos supera al beneficio de tenerlos, y acabas escribiendo un motor de grano fino con un grafo de grano grueso. Por eso este nivel está donde está y no como un apéndice: no es una comodidad ergonómica, es un requisito arquitectónico.

Cierre del nivel

Dos variables de módulo, dos estructuras. El observador construye el grafo de dependencias y responde a qué recalcular. El dueño construye el árbol de propiedad y responde a qué destruir. Las dos se gestionan con el mismo patrón de guardar y restaurar, las dos se pierden al cruzar una frontera asíncrona, y las dos tienen su primitiva de captura y restauración.

Lo que queda por decidir es cuándo corre todo esto: si los efectos se ejecutan en el momento de la escritura o se agrupan y difieren. Ese es el nivel 8.

⚔️ Ensambla y verifica
  1. Implementa el módulo completo de esta lección sobre tu motor.
  2. Ejecuta las cuatro pruebas y comprueba que pasan.
  3. Elimina la línea de desatado en desechar y comprueba cuál de las pruebas falla.
  4. Añade el campo padre y el contexto heredado, y escribe una prueba que verifique que un valor provisto arriba llega a un efecto tres niveles abajo.