babel-plugin-jsx-dom-expressions: el compilador que es Solid
El grueso de Solid no vive en tiempo de ejecución sino en un transformador de Babel. Cómo `babel-plugin-jsx-dom-expressions` —un motor genérico de compilación de JSX a DOM— parte cada plantilla en una porción estática clonable y unas ligaduras reactivas, y cómo Solid lo convierte en su compilador enchufándole su runtime vía `moduleName`. La cadena `vite-plugin-solid` → `babel-preset-solid` → plugin, la partición estático contra dinámico, el trato distinto a nativos y componentes, y los tres modos de generación dom, ssr y universal.
La idea más contraintuitiva de Solid es que apenas tiene runtime. Lo que en React es una librería que interpreta tus elementos una y otra vez, en Solid es un transformador de Babel que lee tu JSX una sola vez —en el build— y emite el código imperativo mínimo que crea y parchea el DOM. Ese transformador se llama babel-plugin-jsx-dom-expressions, y no pertenece solo a Solid: es un motor genérico de compilación de JSX a DOM al que Solid enchufa su sistema reactivo. Verlo de cerca es dejar de tratar a Solid como magia para entenderlo como una tubería de tres piezas.
- Situar
babel-plugin-jsx-dom-expressionsen la cadenavite-plugin-solid→babel-preset-solid→ plugin. - Comprender la partición que gobierna todo: porción estática clonable contra ligaduras dinámicas.
- Distinguir cómo compila un elemento nativo como
divfrente a un componente comoApp. - Conocer los tres modos de generación —
dom,ssryuniversal— y qué runtime inyecta cada uno.
Una tubería de tres piezas
Cuando Vite procesa un .tsx, vite-plugin-solid lo intercepta y ejecuta Babel con babel-preset-solid. Ese preset hace una sola cosa esencial: configurar e invocar babel-plugin-jsx-dom-expressions, pasándole las coordenadas del runtime de Solid. El plugin, por sí mismo, no sabe nada de signals ni de solid-js: es un motor genérico —DOM Expressions, de Ryan Carniato— que sabe convertir JSX en plantillas y ligaduras, pero delega las funciones concretas —template, insert, effect, createComponent, memo— en quien lo configure. Solid le dice “impórtalas de solid-js/web”, y así un compilador neutro se convierte en el compilador de Solid.
// lo que babel-preset-solid entrega al plugin, en esencia
{
moduleName: "solid-js/web", // de aqui salen template, insert, effect, createComponent
generate: "dom", // "dom" | "ssr" | "universal"
hydratable: true, // emite claves de hidratacion para SSR
delegateEvents: true, // un solo listener global por tipo de evento
wrapConditionals: true, // envuelve && y ternarios en memos cuando conviene
builtIns: ["For", "Show", "Switch", "Match", "Suspense"],
}
Ese moduleName es la costura de toda la arquitectura: cámbialo y el mismo plugin genera código para otro sistema reactivo. DOM Expressions nació como un experimento de Ryan Carniato para demostrar que se podía compilar JSX a DOM reactivo sin runtime pesado; Solid es su encarnación de producción, pero el compilador conserva esa neutralidad y no menciona createSignal por ningún lado, solo template, insert, effect y compañía.
¿Y por qué un plugin de Babel y no una transformación de esbuild o SWC? Porque la partición estático/dinámico exige recorrer y reescribir el árbol de sintaxis con precisión quirúrgica, y Babel es la herramienta madura para manipular AST de JavaScript. Han existido intentos de portarlo a otros compiladores por velocidad de build, pero la implementación de referencia —la que define qué significa compilar JSX en Solid— sigue siendo esta.
Conviene ver el encabezado que el plugin inyecta en cada módulo compilado: una línea de imports desde solid-js/web, con alias _$, que reúne solo los helpers que ese módulo realmente usa.
import {
template as _$template,
insert as _$insert,
effect as _$effect,
createComponent as _$createComponent,
delegateEvents as _$delegateEvents,
} from "solid-js/web";
Si un módulo no delega eventos, delegateEvents no aparece; si no contiene componentes, tampoco createComponent. El propio conjunto de imports es ya un resumen de qué clases de trabajo hace ese fichero, y leerlo primero te orienta antes de bajar al detalle.
La partición: estático contra dinámico
El acto central del compilador, repetido para cada árbol de JSX, es una partición: decidir qué es demostrablemente constante —horneable en una cadena de HTML— y qué depende del grafo reactivo. El criterio es un análisis estático llamado isDynamic: una expresión es dinámica si es una llamada como nombre(), un acceso a miembro que podría cambiar, o cualquier cosa no probadamente constante. Todo lo estático entra en la cadena que recibe template(); todo lo dinámico sale envuelto en insert() o effect().
const saludo = <p class="card">Hola, {nombre()}</p>;
import { template as _$template, insert as _$insert } from "solid-js/web";
const _tmpl$ = _$template(`<p class=card>Hola, `); // la parte fija, una vez
const saludo = (() => {
const _el$ = _tmpl$(); // clona el nodo
_$insert(_el$, nombre, null); // la parte viva se liga aparte
return _el$;
})();
Dos hechos sobre esa salida. El class="card" era constante, así que se endureció dentro de la cadena —y el compilador incluso soltó las comillas innecesarias—. Y {nombre()}, al ser una llamada, salió de la cadena y se convirtió en un insert que recibe nombre —el accessor, no su valor— para que el runtime pueda releerlo. Lo estático se clona; lo dinámico se suscribe.
El insert esconde una optimización fina. Si la expresión dinámica es exactamente una llamada sin argumentos como nombre(), el compilador pasa el accessor desnudo —insert(el, nombre)— y se ahorra una envoltura. Pero si la expresión combina fuentes, como {nombre() + apellido()}, no puede pasar una referencia única: la envuelve en un thunk —insert(el, () => nombre() + apellido())— para que el efecto de render relea ambas señales. En ambos casos hay reactividad; solo cambia si viaja una referencia o una función anónima.
Nativos contra componentes
La partición solo aplica a elementos nativos —etiquetas en minúscula que mapean a nodos reales del DOM—. Un componente —una etiqueta con mayúscula inicial como App o Boton— no es un nodo que el compilador pueda clonar: es tu función. Así que compila a una llamada a createComponent con un objeto de getters reactivos.
const ui = <Boton color={color()}>Ir</Boton>;
const ui = _$createComponent(Boton, {
get color() { return color(); }, // getter: preserva la suscripcion
children: "Ir",
});
Cada prop es un getter, no un valor copiado: por eso destructurar la firma de props —que lee el getter una vez— rompe la reactividad. Y createComponent ni clona ni compara: invoca tu función una sola vez, dentro del contexto reactivo del padre, y devuelve el DOM que produzca. Esto alcanza al control de flujo: Show, For y Switch compilan igual, a createComponent con getters; la lista builtIns solo le dice al compilador cuáles puede optimizar de forma especial.
El mismo trato reciben las demás piezas que no son un nodo clonable. Los children de un componente viajan dentro del objeto de props, como getter si son dinámicos; y un fragmento <>...</> no es más que un array de nodos que el compilador ensambla sin envoltorio ni coste. Nada de esto entra en una plantilla, porque nada de esto es HTML estático que se pueda clonar.
El spread lo confirma. Sobre un elemento nativo compila a spread(), que aplica las propiedades y las mantiene reactivas; sobre un componente compila a mergeProps, que combina las fuentes preservando sus getters.
const fila = <Fila {...datos()}>{titulo()}</Fila>;
const fila = _$createComponent(Fila, _$mergeProps(datos, {
get children() { return titulo(); }, // hijo dinamico como getter
}));
Ni una plantilla ni un cloneNode a la vista: un componente es una llamada que devuelve DOM, no un nodo que se copie.
Tres modos de generación
La opción generate decide a cuál de los tres runtimes de Solid apunta el plugin, sin tocar tu JSX.
dom
El modo cliente. Emite template(), cloneNode, insert() y effect(). El HTML se hornea una vez y cada instancia es un clon barato parcheado por efectos.
ssr
El modo servidor. No clona nada: emite concatenación de cadenas con ssr() y escape(), escapando solo las partes dinámicas. Produce el HTML que el cliente hidratará.
universal
El modo de renderers a medida. En vez del DOM, dirige las mismas instrucciones a un renderer creado con createRenderer de solid-js/universal, como solid-three o destinos de terminal.
El contraste se ve en una sola línea. El mismo <p>{x()}</p> produce, en dom, un template y un insert; en ssr, una concatenación de cadenas que escapa solo la parte viva y no clona nada.
// generate: "ssr"
_$ssr(["<p>", "</p>"], _$escape(x())); // sin template, sin effect: solo HTML
Que un mismo compilador sirva a tres destinos es la razón profunda de que SolidStart haga SSR y streaming con los mismos componentes que escribes para el cliente: no es una segunda implementación, es el mismo JSX pasado por el plugin con otro generate. Y es también la prueba de que la neutralidad de DOM Expressions no era un lujo académico: gracias a ella el ecosistema puede reactivizar el DOM en el cliente, cadenas de HTML en el servidor y árboles de objetos arbitrarios en un renderer propio, todo con una sola gramática de entrada.
La inversión mental que hay que hacer con Solid es aceptar que el grueso del framework no vive en tiempo de ejecución sino en tiempo de compilación. React es sobre todo una librería: interpreta tus elementos, mantiene un árbol virtual y decide en cada actualización qué reconciliar, y por eso su runtime pesa y su trabajo crece con lo que muestras. Solid invierte el reparto: babel-plugin-jsx-dom-expressions hace casi todo el trabajo pesado antes de que el navegador vea una línea, y lo que queda para el runtime —template, insert, effect, createComponent— es un puñado de funciones diminutas que clonan un nodo, lo insertan y registran un efecto. Esto explica de golpe casi todas las rarezas de Solid que confunden a quien viene de React. No destructuras props porque el compilador necesita ver los accesos para envolverlos en getters. El componente corre una vez porque no hay ciclo de render que repetir, solo un grafo que propaga. No hay key porque no hay reconciliación de árboles que orientar. Ninguna es una restricción caprichosa: son la sombra que el compilador proyecta sobre la sintaxis. Y como el plugin es genérico, esa misma sintaxis alimenta tres runtimes —cliente, servidor y universal— con solo cambiar una opción, algo impensable en un framework cuyo comportamiento vive atrapado en su tiempo de ejecución. Cuando algo te sorprenda, no preguntes qué hace la librería: abre el módulo compilado y lee las llamadas. Ahí, en un lenguaje más honesto que el JSX, está escrito el framework entero.
- En un proyecto Solid, abre
node_modules/babel-preset-solidy localiza la opciónmoduleNameque apunta asolid-js/web; entiende que ahí se enchufa el runtime. - Escribe
const v = <p class="x">Hola {nombre()}</p>y, en las herramientas de red de Vite, abre el módulo.tsxya compilado; identifica eltemplate()y comprueba qué quedó horneado y qué salió comoinsert. - Cambia
{nombre()}por{nombre() + "!"}y observa cómo elinsertpasa de recibir el accessor desnudo a recibir un thunk anónimo. - Añade un componente
<Boton dato={x()} />al lado y observa cómo compila acreateComponentcon un getter en vez de a untemplate. - Cambia mentalmente
generatea"ssr"y describe cómo desaparecerían loscloneNodea favor de concatenación de cadenas.