Hoy hace exactamente un mes que me mudé a Estados Unidos; después de aterrizar en Carolina del Norte y organizar el día a día, ya me había recorrido prácticamente la zona y los sitios de interés más cercanos, así que me quedé sin nada que hacer. Pero resulta que no soy alguien capaz de estar ocioso: en cuanto tengo tiempo libre me busco algo que hacer retomar el blog cuenta como algo, ¿no?, solo que aquí en Estados Unidos la logística no es muy cómoda, así que de momento no puedo diseñar placas. No me queda más remedio que volver a escribir algo de software para matar el rato y, ya de paso, retomar y tapar los agujeros que dejé hace unos meses.
Pero antes de ponerme a criticar a los demás, seguramente convenga rellenar algo de contexto sobre seam & SeamJS (x qué demonios es esta cosa total, la entrada anterior fue hace varios meses, lo más probable es que ya lo hayáis olvidado por completo, y el README.md de GitHub me da pereza tocarlo por ahora
El renderizado como protocolo
La interfaz de usuario debe describirse, no ejecutarse.
En pocas palabras, Seam1 es ante todo un protocolo, no otro SSR runtime más. Es decir: en tiempo de compilación se genera un esqueleto HTML con slot, y en tiempo de ejecución o de petición solo se meten valores en esos slot; if / each / match son igualmente nodos de protocolo fijados en el esqueleto. Así que, cuando entra la petición, no se ha ejecutado ni un solo componente de UI, y lo único que tiene que hacer el servidor es . Con eso ya no hace falta que sea Node ni Bun: un backend en Rust / Go también puede rellenar un HTML, ¿no?, ¿qué dificultad tiene eso?! Aunque en realidad más adelante tampoco se elimina del todo el JS Runtime; quiero decir que puedes prescindir de él siempre que aceptes ciertas limitaciones Sea como sea, la mejora global de rendimiento es sin duda exponencial, porque conseguir la mayoría de los efectos de SSR ya no exige ejecutar un UI render en tiempo de ejecución / de petición...
¿El modelo en tiempo de petición? 2
Más o menos desde la segunda mitad de este año, port este sitio por completo de TanStack Start React a SvelteKit Svelte. Terminada toda la migración, comprobé que efectivamente es muy bueno: el coste de rendimiento de ssr bajó casi un orden de magnitud, el coste de hidratación es mínimo, funciona de forma muy cómoda, y la filosofía de compilación del framework también es estupenda y muy ligera. Pero en la parte de Kit siempre hay cosas que me dejan insatisfecho. La mayoría se puede resolver con plugins al fin y al cabo está armado con Vite, pero siempre queda alguna parte que no se puede tocar; aunque lo más probable es que el problema sea mío, porque lo que seam quiere cambiar seguramente choca de raíz con cualquier framework meta existente en el fondo estoy desafiando su modelo de ejecución (ejecutar la función render en cada petición), su frontera de datos (load y UI pegados) y su capacidad de backend (atados al runtime de JS).
Pseudo-SSR 3
Ni hace falta pensarlo: seguro que alguien pregunta si esto no es simplemente Astro / Qwik / Marko. : seam, por la forma del lenguaje, es lo más cercano a Marko; por enviar poco JS por defecto, a Astro; por no replay hydrate, a Qwik. Pero su request-time sigue ejecutando un programa de interfaz: los render propios de island, aquel único SSR de Qwik, las funciones de plantilla JS que compila Marko; todas siguen ejecutando un trozo de renderer en cada petición.
Esas mil {#each}, en el fondo, también son yo pegando HTML, así que no es que no genere un documento4; la diferencia está en que lo que pego es un protocolo ya autocontenido, y no Tree de componentes regenerados. Seam quiere AST → IR → HTML con slot; en la petición solo se interpreta el protocolo, más el injection de las zonas de datos dinámicos, así que ya no hace falta ejecutar esa parte de las funciones de UI render. Por eso cualquier backend puede rellenarlo, y al residual-ificar esa última capa queda resuelto el problema del vendor lock in JS5. Así que en realidad mi dirección general y la de lo ya existente que he citado ni siquiera están en la misma línea (x)
Adoptar
Seam hidrata, pero ni al modo tradicional ni al modo de Qwik. La hidratación tradicional consiste en que el cliente vuelve a render y lo coteja con DOM; Qwik SSR un HTML más el estado de los componentes, el cliente resume y no replay; en cambio, en el request-time de Seam ni siquiera hace falta ejecutar el UI renderer: el esqueleto de hidratación, la primera pantalla y los datos de hydrate salen de un mismo injection. El cliente recibe un DOM ya correcto más los mismos datos, y engancha los eventos y el client state siguiendo el IR generado en tiempo de compilación adopt; no es un replay, ni por supuesto resume todo un árbol de componentes.
Justo por eso, «renderizar una vez en cada lado y luego cotejar», ese tipo de mismatch6 ha pasado de ser lo normal a ser un estado ilegal. No estoy afirmando que la implementación esté al 100 %, sino que esa clase de desviación no es legítima dentro del modelo: si aparece, es un bug de mi implementación de compiler / injector y puede ser fix; los ejemplos típicos son (el esqueleto no pasó por el parser de HTML, el escapado no cuadra, el IR y el esqueleto están desincronizados).
Por supuesto, skeleton tiene que ser él mismo parse-stable7 ; meter comment a lo loco dentro de table es una implementación mal hecha, y no hay que cargárselo a mi modelo. Lo que el build-time calcula es el mapeo de hidratación (qué es estático, qué es slot, qué se enlaza), no dejar los datos calculados de antemano; eso sí, fija el tipo de datos de este slot. Los valores en sí se pueden seguir cambiando a voluntad en request-time; solo que HTML y payload tienen que ser el mismo.
¿El mismo grafo?
Astro Cierto que su primera pantalla también es un documento y que después puede ClientRouter, pero la diferencia está en que su parte dinámica sigue siendo islands que render por separado, y ClientRouter se parece en el fondo más a document morph que al mismo grafo de componentes que sigue vivo tras la hidratación. Por eso el stack técnico de Astro no sirve para esto; no es que esté mal, es que no está diseñado para que lo uses así. Puedes decirme, claro, que ahí está la View Transitions API8, y entonces te pregunto yo: ¿la View Transitions API da de comer?, ¿te deja envolver las páginas con una animación de Motion9… me estoy yendo por las ramas;
De todos modos, antes de la hidratación nadie puede mover nada, y que no haya animación en el primer fotograma10, antes de que arranque JS, no es culpa de nadie; Seam solo garantiza que injection sea correcto, que document sea correcto y que el punto de entrada client sea correcto. La restricción de verdad está después de la hidratación: la navegación de client mete los datos en ese grafo que sigue vivo (el layout persiste), en lugar de volver a pasar por injection para cambiar document. En resumen: lo que Seam retoma tras la hidratación es el mismo grafo de componentes estático.
También está Marko 6, que ya se acerca bastante a lo que tengo en la cabeza (AST de plantilla, granularidad fina en tiempo de compilación, resume en vez de replay); pero frente a Marko las líneas que aún se pueden trazar técnicamente son básicamente dos: una, que request-time sigue ejecutando un artefacto compilado a JS y toda la cadena queda atada al runtime de JS; y otra, que el autor escribe en Marko y no en Svelte. Estoy convencido de que, en 2026, la mayoría de la gente que habla de frontend solo considerará React, Vue, Svelte, Solid como UI Stack medianamente mayoritaria; si yo no lo mencionara, muchos ni siquiera sabrían qué es Marko. Así que construir sobre Svelte no es exactamente «copiar el principio y añadirle un backend»: prefiero explicarlo como tomar prestada una UI que alguien ha escrito, que la gente usa y a la que otros le corrigen bugs y le sacan brillo sin parar; todo cosas que ya nos resultan familiares.
Otra cosa: lo que ahorra el «sin hidratación» de Qwik es el replay del lado del cliente, pero no ahorra ese único SSR del servidor11(?), así que es una lástima. Ahora mismo no hay ningún framework que me satisfaga, ni ninguno que me dé suficiente API como para tener la posibilidad de añadir cosas por mi cuenta; por eso hace unos meses me puse a hacer ese chisme llamado SeamJS; y hoy justamente vengo a replantear su roadmap.
Hoy estoy completamente de acuerdo con «Svelte: for building, not frameworking.12»
React
Hablemos primero de React: dejando de lado su atadura comercial con Vercel Next.js y que tiene su propio roadmap, lo primero es que está plagado de CVE, y solo por eso ya estás condenado a vigilar las Social Media a todas horas por si se ha reportado y publicado una nueva vulnerabilidad. Los Router de los demás están básicamente inutilizables, sobre todo Remix13, pero en el 26 hay una opción muy especial, y es TanStack Start. Llevo usando TanStack Router desde hace mucho, así que puedo decir que salvo por un estilo de nombrado y unas estructuras de datos no demasiado bonitos14, en cuanto a funcionalidad es la única opción que apruebo. Es algo nueva, sí, pero resuelve el problema de velocidad en el primer acceso a una página en frío que el proceso next-server de Next.js 16 sigue sin resolver. Next.js siempre ha tenido inexplicablemente ese tirón, y no es por falta de potencia del servidor: en realidad lo atribuyo a que, o bien el Load de arranque no ha terminado, o bien runtime todavía está compilando algo. Además, esa estructura no encaja bien con el principio de instancias multirregión que arrancan bajo demanda, al estilo de Cloudflare Worker; solo un proceso Node largo tradicional como el de Vercel15 se libra de este problema (?) Me he ido bastante por las ramas; sobre Next.js ya escribiré un artículo entero para despotricar, pero TanStack16 me parece de verdad lo bastante maduro para uso comercial: al menos yo lo he usado en el sitio de ventas de cierto proyecto. Pero esta es solo una de las dos únicas opciones (x)
Vue
En cuanto a Vue: personalmente, cuando migré de Vue 2 a Vue 3, dejé de estar de acuerdo con la sintaxis y con esos mecanismos; el Vue que tengo en la cabeza será siempre Vue 2. Es innegable que Vue 3 trae muchísimos feature estupendos, pero comparado con Vue 2 siento que su núcleo ha cambiado, y ha cambiado muchísimo. Además, desde el punto de vista comercial, ¿no está Vue cada vez más completamente atado a la capa meta, controlado por el ecosistema? Su situación se parece muchísimo a la de Svelte: ambos son código abierto, pero en la práctica resulta realmente difícil que un tercero dé un paso al frente y te haga una segunda implementación. Por eso mismo el reparto de cartas que trajo la llegada de TanStack Start golpeó tan fuerte a Next.js; hay que reconocerle el mérito a Tanner Linsley. Y hay otro punto más, un problema de Vue que no se puede pasar por alto: en sus primeros años Vue fue de verdad una innovación; pero cualquier UI Stack, en cuanto su ecosistema crece, se topa con problemas de ecosistema, es decir, ¿ciertos compromisos que aparecen en nombre de la compatibilidad? Este problema es especialmente evidente en React, y el Vue de hoy no es una excepción. Cuando empieza a aparecer este tipo de contenido17, normalmente ya lo considero Slop -- aunque tampoco se puede decir que sea malo, porque una vez que Nuxt se convierte en el siguiente framework «de nivel empresarial», lo primero que se considera ya no es si algo es técnicamente correcto o elegante, sino cierta retrocompatibilidad más estabilidad (x
Así que Evan You en realidad no ha hecho nada mal; incluso podría decirse que alguien tenía que dar la cara como el , y resulta que él era la persona más adecuada, así que solo hizo lo más adecuado…
Solid
El framework SolidJS es muy bueno y su núcleo es lo bastante estable, pero nadie quiere escribirle un ecosistema. Solid es en el fondo el Rust de hace unos años, solo que Rust sí logró aguantar; Rust vive en programación una situación parecida, con poco ecosistema y teniendo que fabricarte tú mismo cada rueda, pero la cadena de herramientas de Rust te deja fabricar esa rueda con soltura y poner en marcha la unidad mínima funcional, así que reinventar la rueda no resulta tan difícil, ¿no? Por desgracia, SolidJS todavía no ha superado esa etapa y le ha caído encima el invierno de la IA; a partir del 26 será especialmente duro. El núcleo de Solid 2.0 ya es muy bueno y muy estable, pero la periferia nunca echó a rodar. La idea es acertadísima, solo que nació a destiempo. Y aunque existan unos pocos proyectos alrededor, están dispersos en uno o dos frentes (TanStack, por ejemplo, tiene un Router experimental para Solid) y no llegan a componer un ecosistema completo. Eso lo deja hoy y mañana en una posición incómoda: sin ecosistema completo, y en esta era de explosión de los Agents, mientras la bola de nieve no eche a rodar, ningún Agent lo elegirá en primer lugar. Y si ni siquiera la IA lo prefiere, eso significa aún menos oportunidades de recibir contribuciones, más aún en una época en la que hasta Linus Torvalds se ha puesto a . Me da mucha pena Solid: buena idea, tecnología acertada, implementación elegante, pero qué lástima haber nacido a destiempo…
«Puede que el yo de antes no lo hubiera considerado un problema, escribirlo todo uno mismo no habría importado. Pero, por desgracia, el yo de ahora ya no piensa así»
El punto de equilibrio
Así que, en este camino, el que se planta justo en el punto de equilibrio perfecto es en realidad solo Svelte. Tiene mejor ecosistema que SolidJS (y mejor año de nacimiento), en rendimiento apenas queda por detrás e incluso, a veces, gracias a su compilador, resulta muchas veces más rápido y mejor que SolidJS?! Claro que aquí habrá quien diga: cuando el número de páginas sube a cierta escala, o cuanto más navega el usuario, ¿el coste de recursos18 de Svelte frente a SolidJS no crece cada vez más? ¿No se multiplican las peticiones de red? Bueno, en teoría es cierto, pero creo que hay que mirarlo junto a las páginas reales: en el 99,9 % de los sitios personales o de contenido, los visitantes básicamente no se quedan mucho rato, y quedarse mucho puede considerarse incluso un caso muy raro. Al menos en mi sitio, ahora mismo los recorridos profundos a base de clics son escasísimos, así que «pasar muchas páginas = Svelte sale perdiendo» no me supone un problema. Pero esa no es la razón por la que elegí Svelte; el núcleo sigue siendo el AST del markup.
Además está el Dashboard, esa clase de cosa que se va abriendo página a página, y ahí esta estadística no ayuda; pero al fin y al cabo los usuarios de un Dashboard son bastantes menos que el público real del contenido, ¿no? Por eso lo de «ver muchísimas páginas dispara el coste» me parece en el fondo un falso problema; al menos por ahora, puede que más adelante lo vea de otra manera, pero en esta era de la información a toda velocidad me cuesta no admitir que esos usuarios son rarísimos; e incluso si alguien explora a fondo por interés y acaba convertido en lector fiel, lo más probable es que esa exploración ocurra solo en la primera visita y que después solo pase a ver los artículos nuevos; en términos de retorno estadístico, ese caso extremo sencillamente no compensa. Por eso prefiero creer que en la mayoría de los escenarios Svelte ofrece un mejor rendimiento global.
Frente a los tres frameworks anteriores, Svelte no solo encaja mejor por su situación y su posición: en realidad también se acerca mucho a la sensación de escribir HTML a pelo; incluso en los primeros tiempos, cuando Svelte 3 apenas se estaba extendiendo y la extensión de archivo .svelte acababa de fijarse, era lo que se recomendaba oficialmente mientras no existía el plugin oficial de VSCode, hasta el punto de que todavía hoy puedes encontrar esta frase en la página del plugin de Svelte:
Si añadiste
"files.associations": {"*.svelte": "html" }a tus ajustes de VSCode, elimínalo.
Además, Svelte me ofrece bastantes ventajas más. Para empezar, no empuja la interfaz hacia JSX más un montón de JS function19; y luego, <style> es un producto del tiempo de compilación y no runtime: lo primero deja la estructura dentro de markup AST, lo segundo permite que el CSS entre directamente en la IR, y Tailwind justo encaja por los dos lados (
Caja negra
Como ya escribí en el artículo anterior, Seam es ante todo un protocolo, no otro runtime de SSR más. En build-time se produce el HTML skeleton con slot; en request-time solo se hace injection; if / each / match también son nodos del protocolo20
Lástima que en su momento elegí React para meterle mano a la interfaz: por desgracia, sobre React resulta muy difícil capturar la estructura de la página y generar variantes, y tocar el compilador supondría tanto trabajo como reescribir uno entero…
Si no se cambia nada, todavía se puede tratar el compilador de React como una caja negra y ejecutar de verdad un montón de render + diff para adivinar la estructura y usarla; decisiones finitas como nullable / enum aún se pueden enumerar por completo, pero en cuanto aparece un predicado como price > 10, el espacio de valores del tipo sencillamente no acaba de multiplicarse. En la V1, ante esto, lo único que te queda es dar los mocks a mano, por ejemplo haciendo tres cortes según price < 10 / = 10 / > 10;
Pero el problema está justo ahí(?) Para lograr ese corte en 3 exacto haría falta que supieras que el punto de corte está en 10, pero el compilador de React es opaco para mí, no sabemos cuál es la condición. Así que el precio del TypeSafe21 acaba siendo que el usuario haga el trabajo del compilador.
En teoría esto no debería recaer en el usuario, porque si no el resultado es escape por todas partes22, y CTR23 sigue cubriendo solo esos pocos nullable / enum, el descubrimiento de estructura queda como si no se hubiera hecho, y ya no sé cuánto sentido le queda a todo esto.
Pero esta vez no se trata de cambiar el protocolo en sí. Lo que dije en su momento sobre skeleton, slot y injection creo que se puede conservar, y el backend puede seguir sin ejecutar código de interfaz; lo que hay que cambiar es la capa anterior al protocolo, la cuestión de cómo se compila la página de respuesta hasta llegar a esos nodos.
Y aquí no me queda más remedio que agradecer el mayor regalo que me ha hecho Svelte -- markup
¡¡¡Porque la estructura del marcado no tengo que adivinarla en absoluto!!!
El AST visible
Solo por el hecho de que el marcado sea de verdad marcado, la estructura de la página tiene toda la posibilidad de convertirse en algo que un compilador pueda leer, y no en un árbol de componentes generado por un montón de JS en tiempo de ejecución. Lo que quiero decir es: ¿por qué una interfaz perfectamente normal tiene que poder dibujarse mediante ejecución arbitraria?No niego este punto de vista, pero lo que quiero decir es que al menos el 97 % del tiempo el dibujado por ejecución arbitraria no hace ninguna falta, se puede consultar aquí con más detalle
Un contraejemplo: lo que hay en React
function Card({ user }) {
return user
? <div className="card">
<Avatar user={user} />
<span>{user.name}</span>
</div>
: null
}En el fondo sigue tratándose de ejecutar una función de JavaScript, y ver qué devuelve. Por muy listo que sea el compilador, el modelo básico al que se enfrenta sigue siendo: JS execution → JSX expression → element tree. En cambio, dentro de Svelte la cosa es muy distinta.
{#if user}
<div class="card">
<Avatar {user} />
<span>{user.name}</span>
</div>
{/if}Lo que le entrega directamente al compilador es
Component
├── IfBlock
│ └── Element div
│ ├── Component Avatar
│ └── Element span
│ └── Expression user.namePara una aplicación Kit normal esto ya está muy bien, pero para algo como Seam, que quiere descomponer server rendering en compile-time skeleton + CTR + SSR fallback, el significado es de verdad mucho mayor que un poco más.
Porque este árbol me dio dos niveles de información a la vez: el primero es el dinamismo de los valores → slot, pero eso ya se podía hacer en la V1; lo más importante es en realidad el segundo, que me dio el dinamismo de la estructura → nodos de flujo de control a nivel de protocolo. En lugar de adivinar la caja negra del compilador de React, el compilador de Svelte directamente deja al descubierto el resultado de frontend de verdad para que yo lo lea, por ejemplo {user.name} pertenece a lo primero, y solo {#if user} a lo segundo.
Por otro lado, los if / each / match del protocolo antiguo siguen siendo válidos. Pero el producto cartesiano de aquel texto anterior creo que hay que acotarlo: la condición para que se sostenga es el espacio de ramas, no el espacio de valores. Con decisiones finitas como nullable / enum / bool las combinaciones son finitas, y enumerarlas pagando con compile-time se sostiene matemáticamente; en cambio, los string / number / timestamp de JTD no son enumerables de por sí, y las ramas que recortan predicados como price > 10, inventory < 5, items.length === 0 no se descubren sólo con el producto cartesiano de los valores de tipo; en sentinel, metas el número que metas, siempre se va por el mismo lado.
Si de verdad se quieren reducir a decisiones finitas, hay que convertir primero derive en bool / enum, y ese paso JTD no lo regala (je). Así que V1 sound solo sobre nullable / enum, no sobre el espacio de valores de un campo cualquiera; el verdadero problema de ingeniería es más bien que esos bloques los adivina diff, y IR no puede explicarse a sí mismo.
Reducción
Pero en la era de Svelte esos nodos se pueden generar directamente a partir del AST, y la razón no es ahorrar unos pocos ms al cambiar de stack, sino más bien convertir el flujo de control de observación24 en lowering -- el descubrimiento de la estructura queda para el AST, las decisiones finitas se resuelven con nodos de protocolo, y ya no hace falta multiplicar N copias de HTML; los tipos están para restringir payload, no para descubrir el árbol. Así que al final, en Svelte, los values en esta Layer se ven tal como sigue ↓
STATIC
<div class="card">
DYNAMIC
{user.name}
STATIC
</div>Este tipo de DYNAMIC muchas veces no necesita el nivel de React, ese「renderizado de verdad25」; basta con mantener el slot.
Aún más fino: Svelte puede incluso descomponer un solo element de esta manera
structure: static
attributes: static
text node #0: dynamicEsto es justo lo que Seam más necesita y lo que más le gusta. En React, para saber si attributes es static, básicamente solo queda la enumeración por fuerza bruta: se pasan todas las entradas posibles, si attribute no cambia es static, y si cambia se le pone un slot.
Con las ramas estructurales es aún peor: hasta para saber si ese trozo de DOM está ahí o no hay que renderizar dos veces y luego hacer diff, mientras que IfBlock de Svelte convierte esa segunda cosa de observable en generable, y ahí está el salto cualitativo.
Pero en realidad, dicho así no es del todo correcto, o resulta demasiado simplificado; siendo algo más precisos: un solo element todavía se puede seguir dividiendo; y attributes no tiene por qué ser static desde el principio, depende de cómo lo escribas.
class="card" → attributes: static
class:active={on} → name static, value slot
{...rest} → opaque, escape hatch
text node {user.name} → slotAquí, por ejemplo, que la primera línea vaya al skeleton no admite discusión, pero es la segunda línea la que muestra dónde Svelte es realmente cómodo: class: / style: dejan el nombre attr en el AST y solo el valor pasa aparte a ser slot, sin tener que marcar todo el element como dinámico. La tercera línea no se diferencia en el fondo del {...props} de React: Seam simplemente la toma como un hatch explícito y ya no tiene que fingir que la analiza; en React, el className="card" literal tampoco hace falta enumerarlo. Lo que hay que enumerar es «si este attr cambia al pasarle otro juego de props». Justo esa última clase de conjetura es la que a Svelte le falta: mientras el nombre siga en el árbol, no hace falta renderizar dos veces para saberlo 🫠
En fin, React obliga a Seam a volver a tratar muchas cosas como un problema de tiempo de ejecución, por ejemplo ↓
const Wrapper = cond ? A : B;
return Wrapper({
children: foo.map(renderItem)
});O bien
return foo && bar
? getLayout()(data)
: something();Claro que el React Compiler moderno puede analizar buena parte de eso, pero trastearlo a fondo significa tener que mantener ese apaño para siempre, y ese coste es sencillamente enorme, tanto en energía como en tiempo... Si en cambio se toca Svelte, aquí no pienso hacer un fork del compilador, en teoría svelte/compiler ya deja expuesto el AST.
Lo que Seam tiene que hacer es leer ese árbol y luego generar su propia RI; la generación de código DOM / SSR oficial se puede dejar tal cual como alternativa de reserva, en teoría solo hay que mantener el descenso de la RI, no Svelte entero, así que la dirección general coincide y habrá pocos conflictos — pero lo que coincide es que la estructura es visible en tiempo de compilación, no el modelo de ejecución; justo por eso el SSR de Svelte / Kit sigue ejecutando en cada petición la función render generada; en cambio, en mi Seam preferiría que request-time no ejecutara esa función y solo interpretara el protocolo (slot + if / each / match)
En la elección de Svelte había en realidad otra consideración: dado que Next.js y React ya están completamente26 controlados por Vercel y convertidos en el framework del mercado, si Seam promete ser compatible con la semántica de React, mi arquitectura acabará teniendo que permitir arbitrary JavaScript → determine tree structure.
Así que la IR de Seam tendría que ser muy conservadora(x). Al final lo más probable es que acabara así: lo que se puede analizar estáticamente se optimiza, y lo que no, se va a SSR. Sería igualmente una página que mezcla CTR y SSR, pero el problema está precisamente ahí, en ese «no se puede analizar», porque en el mundo de React hay demasiados sitios que no se pueden determinar con certeza…
Si no se toca el compilador de React, no queda más remedio que seguir en la misma situación que en la V1: tratar el compilador como una caja negra y adivinar a ciegas lo que produce. Eso resulta enormemente frágil y poco elegante, y hace que los casos límite salten por todas partes.
Invertir
Así que si ahora digo que el lenguaje de componentes de Seam es Svelte, entonces el compilador puede incluso hacerlo al revés: primero structure known por defecto, expression dynamic de forma local, escape hatch donde sea explícito, luego pasar a runtime, y solo cuando de verdad no haya manera, recurrir al final al SSR.
<script>
let { product } = $props();
</script>
<article class="product">
<h1>{product.name}</h1>
<div class="price">${product.price}</div>
<button>Buy</button>
</article>Se puede entender Seam perfectamente como algo parecido a lo siguiente
<article class="product">
<h1><!-- slot 0 --></h1>
<div class="price">$<!-- slot 1 --></div>
<button>Buy</button>
</article>Entonces, en teoría, slot0 = product.name, slot1 = product.price. Esto es justo la residualización en la que ya venía trabajando: la idea central es que la página entera no tiene por qué pasar por el renderizado en servidor, solo se calcula el residuo realmente dinámico.
Ranuras y ramas
Pero la ganancia real al pasar de React a Svelte está en realidad en la capa siguiente; es decir, cuando cambia la estructura tampoco hace falta renderizar dos veces para adivinarla, porque la estructura ya queda fijada en tiempo de compilación y basta con generar directamente la IR correspondiente
<article class="product">
<h1>{product.name}</h1>
<div class="price">${product.price}</div>
{#if product.available}
<button>Buy</button>
{:else}
<p>Sold out</p>
{/if}
</article><article class="product">
<h1><!-- slot 0 --></h1>
<div class="price">$<!-- slot 1 --></div>
<!--seam:if:product.available-->
<button>Buy</button>
<!--seam:else-->
<p>Sold out</p>
<!--seam:endif-->
</article>El valor sigue siendo slot, y la rama es el bloque if del protocolo antiguo. Pero en la versión de React hacen falta varios diff para llegar a ese bloque; en cambio, en Svelte aquí se puede derivar y generar directamente a partir de IfBlock.
Escape
Esto no quiere decir, claro, que JSX no pueda hacerlo. Es solo que, cuando lo escribes de verdad, casi nadie deja la estructura en un sitio bien visible del árbol de ramificaciones: básicamente todo acaba escrito como lo de aquí abajo (jaja)
const price = formatPrice(product);
const body = product.available
? getAvailableView(product)
: getSoldOutView(product);
return <Layout>{body}</Layout>;Y hay otro punto importante: aunque tú sepas escribirlo así, mientras el grueso de React no lo escriba así y los LLM entrenados tampoco lo escriban así, el coste de migración y el coste de desarrollo posterior crecerán sin duda de forma exponencial.
Pero con Svelte aquí la cosa vuelve a ser distinta
UI structure ≈ template AST, JavaScript ≈ values + behavior ya es el pan de cada día aquí
Es incluso la práctica que Svelte más recomienda oficialmente. Esta diferencia es estructural para la forma en que Seam extrae la IR. Pero tampoco lo entiendas como que Svelte carece de estructuras dinámicas: en realidad tiene su propia vía de escape, y además es una de sus sintaxis principales
{@render children()}
<svelte:component this={layout} />
<svelte:element this={tag}>
{@html html}Wrapper = cond ? A : B no desaparece aquí por arte de magia: simplemente se ha reducido de «cualquier expresión de JS» a estos pocos nodos. Para Seam la cosa se vuelve muy sencilla: basta con seguir por defecto el descenso del AST; lo que se ve estáticamente dentro de un hueco es justo de quien es. Lo más habitual es {@render} usado como compose (layout y Card envolviendo children son todos de este tipo), mientras que <svelte:component> / <svelte:element> / {@render} dinámico sí son hatch claros y acaban en runtime o SSR fallback; y en cuanto a {@html}, se puede reutilizar directamente el raw HTML slot que ya había diseñado.
Así que la ventaja real no es que «la estructura siempre se conoce», sino que «la estructura desconocida tiene una lista de referencia» (x
En React lo desconocido es todo el JS; en Svelte lo desconocido son solo las aberturas que dejan esos pocos escapes mencionados arriba, nada más. Al aplicarlo, el alcance de lo que el CTR debe analizar se reducirá de forma notable, sin duda.
CTR
Poder usar Svelte no significa necesariamente poder usar CTR, pero aquí tengo un criterio de referencia ↓
Así la referencia estática queda compose; hatch sigue pasando por la vía de abajo, y de ahí se deduce que la unidad del CTR es el grafo estático de componentes, y no el archivo suelto. La mayoría son structure known por defecto, una minoría explícitamente escape hatch, y SSR fallback casi nunca aparece en el día a día. Todo lo demás que no figure en la tabla se trata sin excepción como opaque -- así no hay nada que adivinar. CTR es solo el execution model sobre ese subconjunto, y aunque me pase a Svelte tampoco pienso garantizar todo el alcance de Svelte27 (x)
CSS
Otra ganancia enorme es en realidad el CSS: en Svelte se puede escribir así:
<div class="card">...</div>
<style>
.card { border-radius: 8px; }
</style>Aquí, cuando Seam compila un componente, dispone al mismo tiempo ya de markup dependency + script dependency + style dependency, y además el estilo es un artefacto estático. Con eso se puede deducir de forma muy agresiva
Incluso se puede ir un paso más allá y dejar el proceso así ↓
Todo el proceso anterior no necesita en absoluto la participación de un entorno de ejecución de JS.
Sin CSS-in-JS
El CSS-in-JS tampoco lo acepto, y no es que React me obligue a admitir una expresión como la siguiente
const Button = styled.button`
color: ${p => p.primary ? "red" : "black"};
`;O
<div css={theme => ({
color: theme.colors.primary
})}>Solo que esa estructura ya se podía esquivar con Tailwind desde la V1. La razón principal para no enchufar CSS-in-JS es que, en cuanto lo enchufas, el CSS vuelve a ser el resultado de una ejecución de JS, y entonces hay que traer de vuelta el runtime de estilos, el registro, la recolección en SSR, el hashing, la consistencia de la hidratación, el contexto de tema, la inserción, el ordenamiento y la deduplicación, todo. Un framework corriente puede tragarse eso; ¡pero Seam ella es distinta! Lo que quiero eliminar es justamente ese tipo de trabajo de servidor y de runtime, así que no hay ninguna necesidad de volver a invitar, por cortesía hacia el ecosistema, lo que acabamos de echar (no tiene ningún sentido
Además, en la época en que apareció CSS-in-JS, lo que resolvía es justo aquello de lo que hoy se encargan Tailwind CSS y Motion. Si ahora Tailwind CSS y Motion ya lo resuelven de sobra, e incluso mejor y con más elegancia, ¿para qué dar marcha atrás? Como dije antes: «en cuanto un ecosistema se consolida aparecen los compromisos28, y rara vez llega una refactorización rompedora que renueve nada». Entonces, si Seam todavía no tiene ecosistema, ¿por qué no ser radical hasta el final?
Pero no sé si «radical» es la palabra adecuada aquí. A favor: frente a los frameworks tradicionales, sí que estoy tirando ese pesado entorno de ejecución de JS del lado del servidor (Node o Bun). En contra: algo como CSS-in-JS se puede abandonar por completo sin que pase realmente nada — prefiero verlo ya como . Las bibliotecas de CSS-in-JS que en su momento fueron más conocidas: Stitches anunció oficialmente en junio de 2023 que ya no se mantiene de forma activa, styled-components entró en estado latente en marzo de 2025, y Emotion está básicamente igual.
Entonces hay que reconocerle el mérito al <style> de Svelte: CSS → compile-time artifact ya es prácticamente indiscutible dentro de Svelte (al menos eso creo yo, y si no estás de acuerdo, entonces tú tienes razón)
Mientras no sea JS → execute → generate CSS → collect → serialize → hydrate, la diferencia será enorme y, lo más importante, quizá las dependencias de CSS puedan entrar directamente en la Seam IR 🤔
Por ejemplo, la compilación de componentes:
ComponentIR {
skeleton,
dynamic_slots,
css,
client_behavior,
server_dependencies
}Fíjate en que esto no es montar un modelo de renderizado aparte: en la V1, skeleton más dynamic_slots, una vez compilados, dan exactamente ese HTML del protocolo antiguo; solo que ahora me resulta más transparente cómo se obtienen skeleton más dynamic_slots.
Supongamos que lo tratamos como se ve abajo ↓
<script>
let { name } = $props();
</script>
<div class="user">Hello {name}</div>
<style>
.user { font-weight: 500; }
</style>Entonces al final se alcanzará sin remedio
ComponentIR
HTML: <div class="user svelte-x">Hello SLOT(0)</div>
CTR: slot(0) = props.name
CSS: .user.svelte-x { ... }
Client: none
SSR: nonePero ten en cuenta que lo que ves arriba es solo una hoja, y además un corte bastante ideal: sin subcomponentes, sin ramas de estructura, sin cliente. El skeleton a nivel de página se compone a partir del grafo estático de componentes; en cuanto en el subárbol aparece un $state o un hatch, el cliente / renderizado en servidor de la representación intermedia deja de ser none.
Pero esto ya está muy cerca del estado ideal al que quiero llevar Seam: un componente de Svelte deja de corresponder a «un renderizador que hay que ejecutar» y pasa a ser un recurso que el compilador de Svelte 5 puede desmontar. Solo con lograr eso, para mí ya es una mejora conceptual enorme.
La propiedad
Además, «no imponer estado global» significa para Seam algo más profundo que la experiencia de desarrollo. Estoy seguro de que en un proyecto de Next.js, o en cualquier framework full-stack de React, ya te has topado con este mazacote de aquí abajo ↓
<ThemeProvider>
<AuthProvider>
<QueryClientProvider>
<I18nProvider>
<RouterProvider>
<App />
</RouterProvider>
</I18nProvider>
</QueryClientProvider>
</AuthProvider>
</ThemeProvider>Por supuesto que esto tiene solución: este anidamiento de Providers en forma de "pirámide" también se puede escribir como Compose Providers. Vendría a quedar más o menos como lo de abajo.
En concreto, se meten en un array todos los Provider que antes estaban anidados, se define una función de composición y al final solo hay que tragarse esa única capa. Pero para mí esto en realidad es engañarme a mí mismo: en el fondo solo se "esconde" el anidamiento, no se elimina de verdad. El anidamiento visual pasa a ser anidamiento lógico, en cierto sentido es aquello de : puro consuelo propio.
const AppProviders = composeProviders([
ThemeProvider,
AuthProvider,
QueryClientProvider,
I18nProvider,
RouterProvider,
]);
function composeProviders(providers: React.FC<{ children: React.ReactNode }>[]) {
return ({ children }: { children: React.ReactNode }) =>
providers.reduceRight(
(acc, Provider) => <Provider>{acc}</Provider>,
children
);
}
function Root() {
return (
<AppProviders>
<App />
</AppProviders>
);
}Un proyecto de React acaba de verdad muy fácilmente, o mejor dicho al 100 %, convirtiéndose en esto, y entonces surge un problema de costura de lo más molesto: «¿cuál es en realidad la entrada de un componente?». En apariencia se puede decir que es <ProductCard product={product}/>, como si solo dependiera de product en sí? Pero en la práctica
depends on:
product
theme
locale
queryClient
router
auth
featureFlags
...Así que el component dependency graph vuelve a ser implícito. Para el traditional SSR no es gran problema: como mucho toca «recorrer entero el árbol de React». Pero lo que Seam quiere preguntar al final es si ProductCard puede hacer CTR de forma independiente, y ahí es donde la cosa se vuelve realmente engorrosa. Incluso se podría decir que habría que cambiar el comportamiento entero del compilador de React, además de la DX y las costumbres de los usuarios, y entonces para qué...
A quien usa Next.js el rendimiento ya le da prácticamente igual29, y lo digo con toda responsabilidad; más bien están pagando por el ecosistema de Vercel, porque si usas Next.js y no lo alojas en Vercel no le sacas ni la más mínima ventaja 😡
Propiedad local
Svelte, en cambio, hace muy fácil que se forme una propiedad local;
<script>
let { product } = $props();
let quantity = $state(1);
</script>Desde la perspectiva de Svelte y del Seam Compiler, esto resulta muy cómodo.
Al fin y al cabo, «entrada: product + estado local del cliente: quantity»la propiedad queda realmente clarísima
product.name → server CTR
quantity → client
button onclick → client
rest → staticPero esto es solo el comportamiento por defecto, no llega realmente a ser una garantía; porque rune module dentro de getContext('theme') y .svelte.ts sigue siendo una entrada implícita, y QueryClient tampoco desaparece por arte de magia. Creo que la actitud de Seam será la misma que con la lista de hatch anterior: lo que se ve se marca en la IR (como dependencia server o propiedad client), y lo que no se puede analizar se trata como opaque; en todo caso, mejor no fingir que «en Svelte no existen los Provider». Aunque tampoco es seguro, quizá investigando un poco más sí encuentre una forma de tratar los Provider
En la era de React, el mayor problema de Seam era que la IR seguía Component → execute renderer → get output; así que lo que yo llamaba al final seam compiler acababa siendo, en cierto sentido, un SSR adelantado, o se puede decir perfectamente que es
«capa universal de orquestación de SSR»
El mismo protocolo
Así que a partir de ahora se puede decir que la UI es Svelte, pero el protocolo seam sigue siendo un protocolo, centrado todavía en slot y injection, si JTD encaja de verdad es algo que aún tengo que meditar más; desde el punto de vista de las decisiones de ejecución, ahora ya está todo completamente estratificado, los slots ya están residual-ificados, y Rust / Go / TS server solo tienen que rellenar a ciegas sin entender nada del código de los componentes, cero JS de verdad; al mismo tiempo, si en el componente quedan derivaciones puras, se puede plantear añadir un generated JS / QuickJS runtime, (ojo, aquí se ejecutan datos, no UI)
Solo lo que lleva hatch acaba siendo SSR fallback, así que el backend, mientras no haga falta un SSR de verdad, puede no ejecutar nada de código de UI -- y por tanto tampoco queda atado a Node NI a Bun. Eso sí (el Go Server se va a dejar de mantener, porque a mí no me dan las fuerzas. Si alguien con buena voluntad tiene tiempo, puede retomarlo y mantenerlo a largo plazo; no es que la arquitectura haya dejado de admitir la independencia del backend)
Dicho de otro modo, solo he recortado la UI, acepto a cualquiera, este tipo de trabajo laborioso y poco agradecido (?) La parte que no cambia sigue pudiendo servir como «Rendering Protocol», y el backend no se limita a JS/TS.
Semántica de las runes
Otro punto muy importante es que las runes de Svelte 5 se llevan muy bien con Seam, aunque Svelte 5 empieza a parecerse un poco más a JS.
let count = $state(0);
let doubled = $derived(count * 2);Pero este JS no es aquel JS30, la diferencia con useState / useMemo / useContext / useEffect es esta: las runes siguen siendo compiler-recognized semantics, así que Seam puede saber que $state pertenece al client, que $derived es derivado y que $effect es client runtime; esto no está al mismo nivel que const foo = someLibraryHook().
Origen de los datos
Aquí hay un punto muy importante: «reconocerlo» no equivale a «poder hacer CTR», y sobre todo no hay que dar por hecho que un $derived vaya a acabar en skeleton. El count * 2 de arriba es precisamente uno de esos contraejemplos, porque su dependencia es $state, así que es un producto derivado del lado del client. Veamos qué aspecto tiene un $derived que sí puede hacer CTR:
let { product } = $props();
let price = $derived(formatPrice(product.price));product viene de $props, y formatPrice además tiene que ser pura y visible; conectado a la tabla anterior, queda así.
$state / $effect / onclick → client
$derived
deps ⊆ server data, pure, visible → CTR derive
deps include $state → client
impure or opaque callee → QuickJS or SSR fallback
markup → follow the table above; runes are not a free CTR passDibujado así quizá se entienda mejor(?)
En fin, lo amable de Runes está en que la propiedad queda marcada en la sintaxis, no en que $derived traiga de serie la condición de CTR. El único criterio sigue siendo, en el fondo, de dónde vienen los datos que muestra la ruta de abajo, si son puros y si se ven (jaja)
Y al lado añado además una regla <style> → static CSS dependency graph, para que toda la visión del mundo quede completamente coherente: el HTML es estructura, el CSS es un artefacto de estilo, el JS es necesariamente cálculo y comportamiento.
Y no aquello hacia lo que el React moderno se desliza con tanta facilidad: todo en JS31. Puede que para el propio React no sea nada malo, pero por desgracia Todo es JavaScript es justo el mundo menos favorable para la descomposición agresiva en tiempo de compilación que quiero hacer
¿Qué se va a entregar? 32
Después de tanto hablar, ¿qué se acabará implementando de verdad (ja)? Lo primero, sin ninguna duda, es cambiar la pila de interfaz a Svelte, y cabe esperar que, una vez hecho, la observabilidad del compilador mejore notablemente. El motivo principal sigue siendo que Svelte obliga y anima a dejar structure dentro de markup AST en lugar de enterrarlo en un flujo de control de JS arbitrario, lo que amplía directamente el margen analizable de skeleton / CTR y hace que CSS vuelva a ser build artifact en vez de runtime concern.
Esto significa que Seam tiene la oportunidad de hacer dependencias de CSS por componente, CSS crítico, tree shaking y CSS diferido, sin tener que mantener además el style runtime de server / client.
Y además la propiedad de los componentes queda por fin limpia; menos Provider / Context / global runtime dependency significa que tendré ocasión de responder del todo: qué valor viene del servidor, en qué punto se puede pasar por CTR, quién necesita client boundary (qué estado pertenece al cliente) y si este component necesita realmente hydrate…
De estos tres, los dos primeros deciden directamente si CTR se queda solo en una optimización o si de verdad puede convertirse en el principal modelo de ejecución33 de SeamJS. Por eso mismo, para mí abandonar React no es necesariamente una concesión técnica: al revés, también puede ser una ruptura estructural. En fin, sigo diciendo lo mismo
En cuanto un ecosistema se consolida, todo acaba en compromisos,
y rara vez llega una refactorización rupturista que innove de verdad
Ya que todavía no he despegado de verdad, es evidente que tengo todas las oportunidades de romperlo todo sin tener que pensar, por ahora, en el coste de migración; y de verdad espero que algún día, tras innumerables rupturas, encuentre el lugar que de verdad me pertenece.
Aunque al final sea yo el único que lo use, sigue siendo mi propio «Experimento 🧪»