Los nodos del grafo: observers y sources
El grafo reactivo de Solid por dentro: los dos tipos de nodo (SignalState y Computation), la doble naturaleza del memo, y la lista de suscripción bidireccional con slots cruzados que permite tejer y desatar dependencias en tiempo constante.
Dos estructuras, un solo grafo
En el archivo signal.ts del núcleo viven dos formas. Una fuente —SignalState— guarda un valor y una lista de quién lo observa. Una computación —Computation— guarda una función que re-ejecutar y una lista de a quién lee. Simplificadas, son así:
interface SignalState<T> { value: T; observers: Computation<any>[] | null; // quien me lee observerSlots: number[] | null; // mi indice en las sources de cada lector comparator?: (a: T, b: T) => boolean; // el equals}interface Computation<T> { fn: (prev: T) => T; // el cuerpo a re-ejecutar state: number; // 0 limpio, 1 STALE, 2 PENDING sources: SignalState<any>[] | null; // a quien leo sourceSlots: number[] | null; // mi indice en los observers de cada fuente owner: Owner | null; // el ambito duenno updatedAt: number | null; // en que tick corri por ultima vez pure: boolean; // memo (true) o efecto (false)}Un createSignal produce un SignalState; un createEffect produce un Computation con pure en false. La distinción pure decide en qué cola se encola la actualización, algo que verás en la lección 2. Por ahora fija la asimetría: una fuente solo produce (tiene observers, no sources); un efecto solo consume (tiene sources, no observers).
Hay un tercer productor de Computation que conviene nombrar desde ya: el propio JSX. Cada expresión reactiva de una plantilla —un contador() interpolado en el texto, un atributo dinámico, una clase condicional— la compila Solid a un pequeño efecto de render, una Computation con pure en false cuyo cuerpo escribe directamente en el DOM. Así que las hojas del grafo no son abstractas: son, literalmente, los puntos exactos donde tu interfaz toca la pantalla. Retén esta imagen del grafo completo —signals como raíces, memos en el centro, efectos de render como hojas que mueven píxeles—, porque es la que explica por qué Solid no necesita un virtual DOM: la arista ya termina en el nodo del DOM correcto.
El memo: fuente y computación fundidas
¿Y el memo? Es el nodo que rompe la asimetría. Un createMemo es un objeto que hereda las dos interfaces a la vez: tiene sources porque lee otros nodos, y tiene observers porque otros lo leen a él.
// Un memo es literalmente la union de ambas carasinterface Memo<T> extends SignalState<T>, Computation<T> {}Esto no es una metáfora didáctica: es la definición real. El memo es el único nodo con las cuatro listas —sources, sourceSlots, observers, observerSlots— pobladas. Por eso ocupa el centro del grafo, mientras los signals son raíces y los efectos son hojas.
Leído de izquierda a derecha, el grafo dice: a y b son observados por total, que a su vez es observado por el efecto de render. Cada flecha es una arista, y cada arista está registrada dos veces: una en cada extremo. Ese doble registro es el tema del resto de la lección.
La lista bidireccional y sus slots
Cuando una computación lee una fuente, hay que anotar el enlace en ambos lados: la fuente debe saber a quién notificar, y la computación debe saber de qué desuscribirse cuando se recolecte. Solid no usa una lista enlazada de punteros: usa arrays paralelos con índices cruzados. Este es el corazón de readSignal, simplificado:
function readSignal(this: SignalState<any>) { if (Listener) { // indice que ocupara Listener dentro de MIS observers const slot = this.observers ? this.observers.length : 0; // 1) yo, el signal, paso a ser una source del Listener (Listener.sources ??= []).push(this); (Listener.sourceSlots ??= []).push(slot); // 2) el Listener pasa a ser un observer mio (this.observers ??= []).push(Listener); (this.observerSlots ??= []).push(Listener.sources.length - 1); } return this.value;}Fíjate en la simetría. Listener.sources y this.observers son las dos mitades de la misma arista. Pero además guardamos dos números: sourceSlots recuerda en qué posición quedó el Listener dentro de this.observers; observerSlots recuerda en qué posición quedó this dentro de Listener.sources. Son punteros de vuelta, índices que se apuntan mutuamente.
Un detalle que delata la obsesión por el coste: las cuatro listas se crean de forma perezosa, con el operador ??=, solo en la primera suscripción. Un signal que todavía nadie ha leído dentro de un ámbito reactivo tiene su observers en null y no paga ni un byte por adelantado. La estructura crece exactamente al ritmo de las dependencias reales del programa, jamás por anticipado, y por eso un grafo con miles de signals inactivos apenas ocupa memoria.
Bajas en tiempo constante
Antes de cada re-ejecución, una computación se desuscribe de todo para volver a descubrir sus dependencias desde cero (lo viste en el nivel 4). Si observers fuese un array normal, quitar a un observador costaría O(n) por el indexOf y el desplazamiento. Los slots convierten esa baja en un intercambio con el último elemento —el clásico swap-remove—:
function cleanNode(node: Computation<any>) { while (node.sources && node.sources.length) { const source = node.sources.pop()!; const index = node.sourceSlots!.pop()!; // donde estaba yo en source.observers const obs = source.observers!; const last = obs.pop()!; // saco al ultimo observer const lastSlot = source.observerSlots!.pop()!; if (index < obs.length) { // si yo no era el ultimo... obs[index] = last; // muevo al ultimo a mi hueco source.observerSlots![index] = lastSlot; last.sourceSlots![lastSlot] = index; // y reparo su indice cruzado } }}La línea decisiva es la última: al mover last al hueco que yo dejo, su sourceSlot guardado quedaría obsoleto, así que lo reparamos en el acto. Sin esa reparación, el índice cruzado mentiría y la próxima baja corrompería la lista. Es delicado, pero es lo que compra desuscribir un grafo entero en tiempo proporcional al número de aristas, no al cuadrado.
Merece la pena cuantificar la ganancia. Con una implementación ingenua —observadores en un array recorrido con indexOf y luego desplazado—, dar de baja una computación con k fuentes, en un grafo donde cada fuente tiene en promedio m observadores, costaría del orden de k por m. Con slots cruzados cuesta O(k): tiempo constante por arista, sin recorrer ni desplazar nada. En un componente que se re-suscribe en cada tick, esa distancia entre lo cuadrático y lo lineal es, a escala, la diferencia entre una interfaz fluida y una que se arrastra.