Hidratación

La hidratación es el proceso de hacer interactivo el HTML renderizado en servidor. La hidratación de EmberKit es ligera y dirigida — en lugar de re-renderizar todo el árbol de componentes, conecta signals a elementos DOM concretos mediante atributos declarativos data-ek-bind.

Cómo funciona

  1. SSR renderiza el HTML completo — Todos los componentes se renderizan a HTML en el servidor. No se necesita JavaScript para ver la página.

  2. El navegador muestra el HTML de inmediato — First Contentful Paint rápido. La página es totalmente visible y usable.

  3. La hidratación escanea bindings — Un script mínimo (~1KB) se ejecuta tras cargar la página. Encuentra elementos con atributos data-ek-bind y conecta signals a nodos DOM vía signal.subscribe().

  4. Actualizaciones dirigidas — Cuando cambia un signal, solo se actualiza el elemento DOM enlazado. Sin virtual DOM, sin diffing, sin re-render.

// Static: pure HTML, zero JS
function Footer() {
  return <footer>&copy; 2025 EmberKit</footer>;
}

// Interactive: signal-driven DOM binding
function Counter() {
  const [count, setCount] = createSignal(0);
  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>+</button>
      <span data-ek-bind={count}>{count()}</span>
      <button onClick={() => setCount(c => c - 1)}>-</button>
    </div>
  );
}

Referencia de data-ek-bind

PatrónComportamiento
<span data-ek-bind={count}>{count()}</span>Actualiza textContent cuando cambia el signal
<div data-ek-bind={open} data-ek-show="opacity-100" data-ek-hide="opacity-0">Añade/quita clases CSS según verdad
<div data-ek-bind={tab} data-ek-show-when="preview">Alterna la clase hidden cuando el signal coincide con una cadena concreta

Enlace textContent (por defecto)

No hacen falta atributos extra — el texto del elemento permanece sincronizado:

const [name, setName] = createSignal('Alice');
return <p data-ek-bind={name}>{name()}</p>;
// When setName('Bob'), the <p> textContent updates.

Enlace con alternancia de clases

Usa data-ek-show / data-ek-hide para alternar conjuntos de clases CSS según un signal booleano:

const [open, setOpen] = createSignal(false);
return (
  <div data-ek-bind={open} data-ek-show="opacity-100" data-ek-hide="opacity-0 pointer-events-none" class="opacity-0 pointer-events-none">
    Panel content
  </div>
);

Enlace por coincidencia de cadena

Usa data-ek-show-when para cambios tipo pestaña:

const [tab, setTab] = createSignal('preview');
return (
  <>
    <div data-ek-bind={tab} data-ek-show-when="preview" class="p-4">Preview panel</div>
    <div data-ek-bind={tab} data-ek-show-when="code" class="p-4 hidden">Code panel</div>
  </>
);

Personaliza la clase de ocultación con data-ek-hide-class (por defecto "hidden").

Componentes que aceptan signals

Los componentes interactivos detectan cuando una prop es un signal (tiene __idx / subscribe) y aplican bindings automáticamente. Esto mantiene el código del consumidor limpio:

const [open, setOpen] = createSignal(false);

// Pass the signal — Modal handles data-ek-bind internally
<Modal open={open} onClose={() => setOpen(false)} />

// Or pass a plain boolean — SSR only, no hydration
<Modal open={true} />

Qué se hidrata

Solo los elementos con data-ek-bind o event handlers reciben JavaScript. Los click handlers se serializan como atributos data-ekclick y se cablean al cargar. Todo lo demás permanece como HTML puro.

function App() {
  return (
    <div>
      {/* Zero JS — pure HTML */}
      <header>
        <h1>My App</h1>
        <nav>
          <a href="/about">About</a>
          <a href="/contact">Contact</a>
        </nav>
      </header>

      {/* Gets hydration — signal binding on value */}
      <span data-ek-bind={count}>{count()}</span>

      {/* Zero JS — pure content */}
      <article>
        <p>This content never needs JavaScript.</p>
      </article>
    </div>
  );
}

Carga diferida por viewport (LazyInView)

Diferir el render de UI pesada hasta que esté cerca del viewport. El sitio de docs usa esto para secciones de la home bajo el hero, la cuadrícula de iconos y demos UI grandes.

Cómo funciona

  1. SSR (por defecto ssr="lazy") — El servidor envía un elemento host ligero con tu fallback (placeholder), no los children completos.
  2. Navegación cliente / hidratación — Tras cada render(), EmberKit ejecuta hydrateLazyInView() en la raíz de la app.
  3. IntersectionObserver — Cuando el host entra en el viewport (más rootMargin), los children se renderizan en el host y hydrateSubtree() cablea click handlers y data-ek-bind dentro de esa sección.

Flujo: carga de página → fallback visible → el usuario se acerca a la sección → LazyInView monta children → hydrateSubtree cablea ese host.

Uso básico

import { LazyInView } from '@emberkit/core';

function HomePage() {
  return (
    <div>
      <Hero />
      <LazyInView
        minHeight="24rem"
        rootMargin="200px"
        fallback={
          <div aria-hidden="true" className="min-h-96 animate-pulse rounded-2xl border border-white/5 bg-white/[0.02]" />
        }
      >
        <FeaturesSection />
      </LazyInView>
    </div>
  );
}

Usa minHeight (o fallback con altura fija) para que el layout no salte cuando aparece el contenido.

Props

PropTipoDefaultDescripción
childrenJSXNode o () => JSXNodeContenido a renderizar cuando sea visible
fallbackJSXNodenullPlaceholder mostrado antes de cargar
rootMarginstring'200px'Pasado a IntersectionObserver (precarga antes de visible)
minHeightstring | numberEspacio reservado en el elemento host
oncebooleantrueDesregistrar loader tras el primer montaje
ssr'lazy' | 'eager''lazy'eager = renderizar children también en servidor
asstring'div'Nombre de tag del host
classNamestringClases en el host

Modos SSR

ModoPrimer HTMLIdeal para
lazy (default)Solo fallbackPáginas largas, demos, secciones marketing bajo el fold
eagerChildren completosCopy crítico para SEO que debe estar en view source

Render prop lazy

Pasa una función cuando los children importan módulos grandes:

<LazyInView fallback={<SectionSkeleton />}>
  {() => {
    // Evaluated only when the section enters the viewport
    return <HeavyChart data={data} />;
  }}
</LazyInView>

Atributos del host (depuración)

Inspecciona el DOM mientras pruebas:

AtributoSignificado
data-ek-lazy-in-viewId de registro para este host
data-ek-lazy-root-marginMargen del observer
data-ek-lazy-loaded="true"Los children ya se montaron
aria-busy="true"Aún esperando (se quita tras cargar)

Pruebas locales

  1. Inicia la app de docs: pnpm dev desde la raíz del repo (o pnpm dev en apps/docs).
  2. Abre /, /docs/icons o /docs/ui.
  3. Confirma placeholders con pulse above the fold donde las secciones son lazy.
  4. Desplázate — el contenido debe reemplazar placeholders; los hosts ganan data-ek-lazy-loaded.
  5. Opcional: DevTools → Network → limitar a Fast 3G, hard refresh, y comparar first paint vs tras scroll.

Tests del framework:

pnpm --filter @emberkit/core test -- src/viewport src/hydration

data-hydrate="lazy" (islas interactivas)

Para elementos que necesitan hidratación pero no de inmediato, márcalos lazy para que el analizador de hidratación los trate como baja prioridad:

<div data-hydrate="lazy" onClick={handleClick}>
  Below-the-fold control
</div>

Complementa LazyInView: lazy in view difiere el render; data-hydrate="lazy" difiere el cableado de event handlers para HTML ya renderizado.

Listas dinámicas tras SSR

Qué falla

Flujo típico en una home SSR:

  1. Servidor renderiza con lista vacía (sin API en servidor).
  2. Cliente precarga /api/... en setCache() antes o después de render().
  3. Hidratación mantiene la cuadrícula vacía del servidor en el DOM.
  4. Un signal se actualiza con datos obtenidos, pero el markup de la lista no cambia.

La API puede devolver 200 y la caché estar llena mientras la UI sigue mostrando una sección vacía.

Patrones recomendados

1. Forzar un re-render en cliente de la ruta cuando llegan datos async y el DOM de la lista sigue vacío:

function refreshCurrentRoute() {
  const url =
    window.location.pathname + window.location.search + window.location.hash;
  history.replaceState(null, '', url);
}

createEffect(() => {
  void (async () => {
    const list = await fetchProjects();
    setProjects(list);
    const grid = document.getElementById('portfolio-grid');
    if (list.length > 0 && (grid?.children.length ?? 0) === 0) {
      refreshCurrentRoute();
    }
  })();
});

El history.replaceState parcheado en render() llama de nuevo a renderCurrentRoute() con hydrate: false, reconstruyendo el HTML de la ruta desde JSX.

2. Actualizaciones DOM imperativas (como el patrón del índice del blog en docs): renderiza tarjetas con innerHTML o createElement dentro de createEffect en lugar de depender de .map() en JSX.

3. Loaders SSR — obtén datos de lista en un loader de ruta para que el HTML del servidor incluya items. Consulta SSR y SSG.

4. LazyInView con ssr="eager" — cuando el SEO necesita HTML completo en el first paint.

Bindings de input (0.8.0)

data-ek-bind ahora sincroniza controles de formulario durante la hidratación:

ElementoPropiedad enlazada
<input>, <textarea>.value
<select>.value
<button> (signal booleano)disabled
Otros elementostextContent (por defecto)

Impacto en rendimiento

EnfoqueTamaño del bundleFCPTTI
SPA tradicional~100KB+LentoLento
Hidratación EmberKit~1-2KBRápidoRápido
Solo estático0KBInstantáneoInstantáneo
+ LazyInView bajo el foldMismo chunk de ruta; menos render/hidratación inicialCarga percibida más rápida en páginas largasLas secciones se hidratan al hacer scroll

Próximos pasos

  • Signals — API de signals y subscribe
  • SSR — Renderizado en servidor
  • Release 0.8.0 — Dev API, view transitions, notas de hidratación
  • Components — Componentes estáticos vs interactivos
  • Referencia APILazyInView, hydrateLazyInView, clearLazyRegistry