React reconstruye un árbol entero para averiguar qué cambió.

Ascua ya lo sabe: cada dato conoce el nodo del DOM que le corresponde.

0 dependencias en el núcleo84 tests, ninguno necesita navegador160 KB de WASM en el demo0 nodos recreados al hidratar

01El trabajo que nadie tenía que hacer

Un framework con Virtual DOM responde a un cambio de estado reconstruyendo una representación del árbol y comparándola con la anterior para deducir qué tocar. El trabajo es proporcional al tamaño del árbol, no al del cambio.

Ascua establece la relación entre un dato y su nodo una sola vez, al compilar el template. A partir de ahí, cambiar el dato ejecuta la operación de DOM que le corresponde. No hay nada que deducir porque nada se olvidó.

0

lo que se toca del DOM

set_text(nodo#7, "0")

1 escrituras · 0 nodos recreados · 0 comparaciones de árbol

Pulsa. Cada click produce una escritura sobre un nodo de texto que nunca se sustituye: ni un árbol recorrido, ni una comparación.

02Tres primitivas, y ninguna más

Un Signal guarda un valor. Un Effect ejecuta código y observa qué signals lee mientras lo hace; cuando uno cambia, ese efecto —y solo ese— se reejecuta. Un Memo es un efecto que guarda lo que devuelve y no se lo pasa a nadie si no cambió.

let count = Signal::new(0);
let doble = create_memo(move || count.get() * 2);

create_effect(move || println!("doble = {}", doble.get()));

count.set(21);   // imprime "doble = 42"
count.set(21);   // no imprime nada: el memo no cambió

Nadie declaró las dependencias: se descubren al leer.

Las dependencias son bidireccionales, así que un grafo de Rc sería un grafo de ciclos, es decir, fugas. Los nodos viven en una arena y cada uno pertenece a un scope: liberar el scope libera su subárbol, sin recuento de referencias y sin recolector.

03Una closure es reactiva; lo demás, no

view! { dom,
    <button on:click={move |_| count.update(|c| *c += 1)}>
        "Clicks: " {move || count.get()}
        <style>r#"
            button { border-radius: 8px; }
        "#</style>
    </button>
}

{count.get()}          // valor fijo, no vuelve a mirarlo
{move || count.get()}  // este nodo sigue al signal

La distinción es sintáctica, no de tipos.

Mirando el template se sabe qué puede cambiar, sin conocer los tipos ni confiar en ninguna regla implícita. El bloque style se extrae en tiempo de compilación: no deja ni un byte de runtime, solo un atributo en los elementos y un archivo CSS que recoge el build.

let n0 = dom.element("button");
dom.set_attr(&n0, "data-ascua-98909ba2", "");
dom.on(&n0, "click", move |_| count.update(|c| *c += 1));

let n1 = dom.text("Clicks: ");
dom.append(&n0, &n1);

let n2 = dom.dynamic_text(move || count.get().to_string());
dom.append(&n0, &n2);   // el efecto captura n2: ya sabe su destino

Esto es lo que genera el template de arriba. Se puede leer con cargo expand.

04El único sitio con reconciliación

Para texto y atributos no hay nada que comparar. Con una lista dinámica sí: no se puede saber de antemano qué le pasó a cada elemento. La diferencia con un VDOM es el alcance — esto compara una lista de claves, no un árbol, y solo cuando esa lista cambia.

  • 1
  • 2
  • 3
  • 4

4 items construidos en total · reordenar mueve nodos, no los rehace

Cada item recibe su color al construirse. Rota o invierte: los colores viajan con sus items porque son los mismos nodos, movidos. Eso es lo que conserva el foco, el scroll y lo que el usuario estuviera escribiendo.

05El servidor y el navegador, el mismo código

El runtime habla con un trait Backend, no con web-sys. Renderizar en servidor es otro backend, no otro runtime: los mismos componentes producen el HTML en Rust nativo y la interfaz viva en WebAssembly.

Esta página es exactamente eso. Lo que estás leyendo llegó como HTML; solo los dos demos son islas que el navegador activa.

// servidor, en Rust nativo
let html = render_to_string_hydratable(|dom| {
    island(dom, "app", "", |dom| app(dom, estado))
});

// navegador, en WASM
let (montajes, stats) = hydrate_islands(backend, &islas);
// Estadisticas { adoptados: 37, creados: 0 }

Cifras reales del demo del repositorio, medidas en el navegador.

Hidratar es adoptar, no rehacer. El servidor numera los elementos de cada isla en el orden en que el compilador los construye; el cliente construye en ese mismo orden y reclama cada nodo por su número. Los marcadores llevan el suyo dentro del comentario, porque los comentarios no admiten atributos.

06Frente a los de siempre

AscuaReactSvelteLeptos
Actualiza el DOMdirectodiffdirectodirecto
Virtual DOMnonono
LenguajeRustJS/TSJS/TSRust
Runtime en el bundleWASM~45 KB~2 KBWASM
Deps del núcleo0varias
SSR + hidratación
CSS scopedbuild timenobuild timeno
Auditable de un tirónnono

La columna que importa no es ninguna de estas: es si puedes leer el mecanismo entero en una sesión y repararlo sin esperar a que lo arregle otro. El núcleo reactivo son 600 líneas y cero dependencias.

07Empezar

[dependencies]
ascua = "0.1"

# y en el repositorio, el demo completo:
cd examples/demo
./build.sh                    # wasm del cliente + html del servidor
python3 -m http.server 8080

Hace falta Rust con el target wasm32-unknown-unknown y wasm-bindgen-cli. El resto del pipeline es cargo: no hay bundler dueño de nada, y el resultado son un .wasm y un .html que se despliegan en cualquier hosting estático.