Saltar al contenido

El runtime reactivo de Solid 2.0: el signal que suspende

La mecánica interna del núcleo reescrito de Solid 2.0, no ya la experiencia de escribir datos sino cómo funciona por dentro. El nodo reactivo gana un estado —limpio, revisar, pendiente, en error— que el grafo transporta como transporta los valores; el mecanismo por el que una lectura no lista suspende la computación que la lee en vez de devolver undefined; el planificador que sostiene dos versiones del grafo durante una transición; y las projections del nuevo store. Por qué async transparente es una propiedad del runtime, no una API.

El nodo con estado, no solo con valor

En el runtime de 1.x, un nodo reactivo —signal, memo, efecto— guarda un valor y una lista de suscriptores, y el grafo propaga una sola noticia: “esto cambió”. Un memo puede estar limpio o sucio (necesita recalcular), y punto. El runtime de 2.0 amplía esa máquina de estados. Cada nodo puede estar limpio (su valor es válido), por revisar (una fuente cambió y hay que comprobar si el resultado difiere), pendiente (depende de algo asíncrono que aún no resuelve) o en error (una fuente rechazó). Lo pendiente y el error dejan de vivir en un createResource aparte y se vuelven estados de primera clase de cualquier nodo.

La consecuencia es que el grafo aprende a transportar dos cosas nuevas además del valor: la condición de “todavía no” y la de “falló”. Igual que en 1.x un cambio se propaga marcando sucios a los descendientes, en 2.0 lo pendiente se propaga marcándolos pendientes, y el error marcándolos en error. La topología es la misma —las mismas aristas, la misma dirección— pero por ellas viaja más información. Ahí está el corazón del async transparente: no es una API, es que el nodo sabe estar pendiente.

El contraste con 1.x es nítido. Allí, el estado de carga vivía fuera del grafo reactivo: un createResource guardaba su propio booleano loading, y el resto de nodos —memos, efectos— no tenían ni idea de que un dato estaba en vuelo; para enterarse, tú leías loading a mano y ramificabas. El grafo transportaba valores y solo valores; lo pendiente era un dato de aplicación, no un estado del sistema. La reescritura de 2.0 hace la promoción: lo que antes era un booleano que tú administrabas en un rincón se convierte en un estado que el nodo posee y el grafo propaga, con la misma naturalidad con que siempre propagó lo sucio.

TSX
// 1.x: el estado de carga vive fuera del grafo y lo compruebas a manoconst [datos] = createResource(id, cargar);const inicial = () => (datos.loading ? "..." : datos().nombre[0]);// 2.0: el estado vive en el nodo; leer suspende, no devuelve un centinelaconst datos = createAsync(() => cargar(id()));const inicial = () => datos().nombre[0]; // sin comprobar loading en ningun lado

El signal que suspende

Aquí está la pieza que da nombre a la lección. En 1.x, leer un recurso que aún carga te devuelve undefined y tú compruebas loading para no romper. En 2.0, leer una derivación asíncrona que no ha resuelto no devuelve nada: lanza una señal interna de “no listo” que interrumpe la computación lectora. Esa computación —un memo, un efecto, una expresión del JSX— no continúa con un valor falso; se marca pendiente, se aparta, y se reprograma para volver a ejecutarse cuando la dependencia resuelva. Es el mismo principio que el Suspense de React —lanzar para señalar “no puedo continuar”— pero integrado en el grafo de grano fino en lugar de en un ciclo de re-render.

TSX
import { createAsync } from "@solidjs/router";const usuario = createAsync(() => getUsuario(id()));// En 2.0 esta lectura no devuelve undefined si usuario no llego:// SUSPENDE esta computacion y la reprograma cuando resuelva.const inicial = () => usuario().nombre[0];

El detalle crucial es quién suspende. No suspende el signal: suspende la computación que lo lee. Si inicial lee usuario() y no está listo, inicial queda pendiente; y cualquier cosa que lea inicial hereda esa pendencia, porque leerá una fuente pendiente y suspenderá a su vez. Así la condición sube por el grafo sin una sola línea tuya, arista por arista, hasta toparse con la frontera —Suspense— que declara dónde materializar la espera. La suspensión es local y automática; la coordinación es de la frontera. Ese reparto es todo el modelo.

El grano fino de la re-ejecución es lo que hace este mecanismo barato. Cuando la promesa resuelve, no se re-ejecuta el componente ni el árbol: se re-ejecuta solo la computación que había quedado pendiente y las que dependían de ella, exactamente como una actualización normal reevalúa solo los memos afectados. Suspender y reanudar es, desde el punto de vista del runtime, un caso particular de la propagación que ya existía: un nodo se invalidó, su valor no estaba, y cuando llegó se revalidó. No hay maquinaria de Suspense aparte del grafo; es el grafo mismo tratando “todavía no” como una razón más para no tener valor aún.

El planificador: dos verdades en el tiempo

Si lo pendiente se propaga y las computaciones suspenden, alguien tiene que decidir cuándo se muestra el resultado. Ese alguien es el planificador, reescrito en 2.0 para gestionar transiciones como parte del núcleo y no como un añadido. Su tarea es sostener, durante una transición, dos versiones del grafo: la comprometida —lo que el usuario ve ahora— y la pendiente —la que se prepara mientras las promesas resuelven—. Las escrituras en transición mutan la versión pendiente en una rama clonada; la comprometida permanece intacta y visible. Cuando toda la rama pendiente deja de estarlo, el planificador la canjea por la comprometida de forma atómica, en un solo instante, sin estados intermedios visibles.

TSX
import { useTransition } from "solid-js";const [pending, start] = useTransition();// La escritura entra en la rama pendiente; la vista actual no parpadea.// El planificador canjea cuando toda la rama resuelve.start(() => setRuta("/perfil"));

Este es el motivo de que una transición no produzca parpadeo: nunca hay un momento en que la vista muestre datos a medias, porque la versión comprometida no se toca hasta que la pendiente está entera. El planificador de 2.0 formaliza además el flush: agrupa las notificaciones y evalúa los memos de forma perezosa —empuja la noticia de cambio, tira del recálculo solo cuando alguien lee—, ahora con conciencia de lo asíncrono. La reactividad sigue siendo push-pull, pero el pull puede toparse con un “no listo” y suspender, y el push puede arrastrar pendencia además de cambio.

Projections y el store reescrito

La última pieza del runtime 2.0 lleva el async transparente al estado estructurado. El store se reescribe para que derivar estado de fuentes asíncronas sea tan de grano fino como derivarlo de fuentes síncronas, y la primitiva que lo encarna es la projection: una derivación que escribe en un store y cuya reactividad es fina hoja por hoja, de modo que un consumidor que lee store.a.b solo despierta si esa hoja cambia, aunque la fuente sea una promesa que se recalcula entera. Es la respuesta a un problema viejo: cómo tener la ergonomía del store —lectura por rutas, actualización quirúrgica— sobre datos que llegan tarde.

TSX
// La projection deriva un store de una fuente async con grano fino por hoja:// leer una propiedad suspende solo si esa propiedad aun no llego.const vista = createProjection((borrador) => {  borrador.usuario = usuarioAsync();  borrador.total = calcularTotal(itemsAsync());});

No necesitas dominar la API final —está estabilizándose— para captar la idea: el store deja de ser una isla síncrona y pasa a participar del mismo sistema de estados que los signals, con lo pendiente y el error propagándose por sus hojas. Con las projections, la frase “cualquier derivación puede ser asíncrona” se cumple también para el estado en forma de árbol, no solo para valores sueltos.

TSX
// Un memo que depende de algo async queda pendiente sin que tu lo marques:// hereda el estado del nodo, igual que heredaria un cambio de valor.const items = createAsync(() => cargarItems());const total = createMemo(() => items().reduce((a, b) => a + b.precio, 0));// mientras items no llegue, total esta pendiente y su lectura suspende

Vale la pena ver cómo estas cuatro piezas encajan en un solo sistema en lugar de cuatro features sueltas. El nodo con estado es la base; la suspensión de la lectura es lo que ocurre cuando ese estado es “pendiente”; el planificador es quien decide cuándo el resultado de esa espera se hace visible; y las projections extienden todo lo anterior del valor suelto al árbol de estado. Ninguna tiene sentido sin las otras: el nodo con estado sin suspensión no serviría de nada, la suspensión sin planificador produciría parpadeos, el planificador sin nodos con estado no tendría qué coordinar. Es un rediseño de una pieza, no cuatro parches, y por eso el async transparente se siente coherente en lugar de acumulado.

Esta coherencia tiene una consecuencia práctica para quien depura: cuando entiendes que hay un solo mecanismo —el nodo con estado propagándose por aristas conocidas—, dejas de buscar cajas negras separadas para la carga, el error y la concurrencia. Un dato que no aparece, un fallback que no se retira, una transición que no compromete: todos son la misma pregunta —qué nodo está pendiente y por qué no ha resuelto— formulada en distintos puntos del grafo. Dominar el runtime a este nivel convierte la depuración de lo asíncrono en la depuración de un grafo, que es algo que ya sabías hacer desde el mundo síncrono.