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.
Ya sabes qué se siente al escribir datos con async transparente: derivaciones que se leen como síncronas y un estado pendiente que aflora en las fronteras. Esta lección abre la caja y mira el cómo. El núcleo reactivo de Solid 2.0 no es un parche sobre el de 1.x: es una reescritura donde cada nodo del grafo gana un estado —no solo un valor— y donde leer algo que aún no llegó no devuelve undefined, sino que suspende la computación que lo lee. Entender este mecanismo por dentro es la diferencia entre usar createAsync de memoria y saber exactamente qué hace el planificador cuando una promesa está en vuelo.
- Ver el nodo reactivo 2.0 como una máquina de estados: limpio, revisar, pendiente, en error.
- Entender el mecanismo de suspensión: una lectura no lista detiene y reprograma su lector.
- Describir cómo el planificador sostiene dos versiones del grafo durante una transición.
- Situar las projections y el store reescrito dentro de este runtime.
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.
// 1.x: el estado de carga vive fuera del grafo y lo compruebas a mano
const [datos] = createResource(id, cargar);
const inicial = () => (datos.loading ? "..." : datos().nombre[0]);
// 2.0: el estado vive en el nodo; leer suspende, no devuelve un centinela
const datos = createAsync(() => cargar(id()));
const inicial = () => datos().nombre[0]; // sin comprobar loading en ningun lado
flowchart TD L[limpio valor valido] -->|una fuente cambio| R[por revisar] R -->|recalcula y estabiliza| L R -->|la fuente async no llego| P[pendiente] P -->|la promesa resuelve| L P -->|la promesa rechaza| E[en error] E -->|reintento| R style L fill:#a6e3a1,color:#11111b style P fill:#f9e2af,color:#11111b style E fill:#f38ba8,color:#11111b
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.
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.
Que una computación suspenda no detiene el navegador ni congela el hilo. La computación pendiente simplemente no produce valor todavía y se registra para re-ejecutarse cuando su dependencia async resuelva; entretanto, el resto del grafo —todo lo que no depende de esa promesa— corre con total normalidad. Suspender es una transición de estado del nodo, no una espera activa. Confundir “suspende” con “bloquea” es el malentendido que hay que desterrar: es lo contrario de bloquear, es la forma de no bloquear mientras se espera.
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.
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.
Sostener dos versiones del grafo suena caro, pero el coste es menor de lo que parece porque lo que se clona es la parte del grafo afectada por la transición, no el árbol del DOM entero. La versión comprometida sigue pintada; la pendiente es un conjunto de computaciones que se recalculan en paralelo con valores nuevos, sin materializar nodos hasta el canje. Por eso una transición sobre una porción pequeña de la página es barata aunque la página sea enorme: el planificador solo duplica el trabajo reactivo que la escritura tocó, y descarta la rama vieja de golpe al comprometer. Es la misma economía del grano fino aplicada al tiempo en vez de al espacio.
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.
// 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.
// 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.
Nodo con estado
Cada nodo puede estar limpio, por revisar, pendiente o en error. Lo pendiente y el error son estados de primera clase.
La lectura suspende
Leer algo no listo no devuelve undefined: detiene y reprograma la computacion lectora, que hereda la pendencia.
Dos versiones del grafo
En transicion, el planificador sostiene la version comprometida y la pendiente, y las canjea de forma atomica.
Projections
El store reescrito deriva de fuentes async con grano fino por hoja: lo pendiente se propaga por el arbol de estado.
El runtime 2.0 no jubila a createResource. Sigue siendo la herramienta cuando quieres los mandos imperativos —mutate para actualizaciones optimistas, refetch puntual, acceso explícito a loading y error como signals—. Lo que cambia es el caso por defecto: leer estado del servidor deja de empezar por “monto un recurso y compruebo loading” y empieza por “escribo una derivación y declaro una frontera”. El recurso pasa de norma a excepción especializada, igual que en el mundo síncrono el memo es la norma y el efecto manual la excepción.
Merece la pena entender por qué este cambio de runtime se siente inevitable y no arbitrario, porque revela la lógica interna de la reactividad de grano fino llevada hasta el final. Durante toda la era de 1.x, lo pendiente fue un dato que tú administrabas: un loading booleano que declarabas, activabas antes del await, desactivabas después, y comprobabas en cada punto de lectura para no romperte contra un undefined. Ese booleano era ruido —código que no describe tu dominio sino el andamiaje de esperar— y estaba condenado a proliferar, porque cada fuente asíncrona nueva traía el suyo y cada consumidor tenía que consultarlo. La reescritura de 2.0 hace una sola cosa profunda: mueve ese booleano de tu código al interior del nodo. Deja de ser algo que administras y pasa a ser un estado que el nodo es, del mismo modo que “sucio” nunca fue un booleano que gestionabas sino un estado que el memo tenía. Y en cuanto lo pendiente vive en el nodo, todo lo demás se sigue por necesidad: si el nodo puede estar pendiente, leerlo cuando lo está no puede devolver un valor falso, tiene que suspender; si suspende, la pendencia sube por las aristas que ya existen; si sube, se detiene en las fronteras que ya declarabas; y si el planificador ya sostenía dos verdades para las transiciones, sostener una pendiente más es la misma maquinaria. No se inventó un paradigma: se reconoció que lo asíncrono era, desde siempre, otra clase de cambio —uno que llega tarde— y que un grafo diseñado para propagar cambios estaba obligado a propagar también ese. El signal que suspende no es una feature exótica; es la consecuencia de admitir que “todavía no” es una respuesta tan legítima como “cambió”, y de dejar que el runtime la transporte por las mismas vías. Quien entiende el runtime a este nivel deja de ver createAsync como una función y empieza a verlo como la punta visible de un nodo que aprendió a esperar.
- Dibuja la máquina de estados de un nodo 2.0 y marca qué transición corresponde a que una promesa resuelva y cuál a que rechace.
- Explica por qué “el signal que suspende” es impreciso y qué suspende en realidad cuando una lectura no está lista.
- Encadena tres derivaciones asíncronas y traza cómo la pendencia de la primera sube hasta la frontera
Suspensea través de las otras dos. - Argumenta por qué sostener dos versiones del grafo durante una transición elimina el parpadeo, y qué se canjea y cuándo.
- Justifica con un caso por qué “suspender no es bloquear” y qué parte del grafo sigue corriendo mientras una computación está pendiente.