Ir al contenido

Renderizar apps

El renderizado en servidor tiene dos mitades: cada app publica un módulo de servidor y el shell lo renderiza dentro de la página.

El contrato es un objeto con un render(props) que devuelve una cadena o { html, styles?, state? }:

interface WuServerModule {
render(props: Record<string, any>):
| string
| { html: string; styles?: string | string[]; state?: unknown }
| Promise<string | { html: string; styles?: string | string[]; state?: unknown }>;
}

Rara vez lo escribes a mano. Los helpers lo construyen a partir de un componente:

apps/cart/server.js
import { renderReact } from 'wu-framework/server';
import App from './App.jsx';
import css from './cart.css?inline';
export default renderReact(App, { styles: css });
Helper Necesita instalado Usa
renderReact(Component, opts) react + react-dom renderToString
renderPreact(Component, opts) preact + preact-render-to-string render
renderVue(Component, opts) vue + @vue/server-renderer createSSRApp + renderToString
renderSvelte(Component, opts) svelte (build de servidor) Component.render() (v4) o render() de svelte/server (v5)
renderSolid(Component, opts) solid-js renderToString de solid-js/web
renderHtml(fn, opts) nada tu propia función

Todos los helpers aceptan { styles }, que puede ser una cadena, un array de cadenas o una función de las props:

export default renderReact(App, {
styles: (props) => props.theme === 'dark' ? darkCss : lightCss,
});

Extras:

Helper Opción extra Significado
renderReact strictMode (por defecto false) Envuelve el elemento en React.StrictMode
renderVue setup(app, props) Se llama con la instancia de la app SSR antes del render: plugins, provides, i18n

renderHtml acepta cualquier función que produzca HTML: una plantilla literal, una llamada a @lit-labs/ssr de Lit, un renderizador de Markdown.

import { renderHtml } from 'wu-framework/server';
export default renderHtml(
(props) => `<h1>Hello ${props.name}</h1>`,
{ styles: 'h1 { font-weight: 600 }' }
);
import { renderApp } from 'wu-framework/server';
const cart = await renderApp('cart', {
module: () => import('./apps/cart/server.js'),
props: { userId: 42 },
});
// cart.html → el <div>…</div> completo
// cart.inner → solo el <template> (para integraciones que construyen el div ellas mismas)
// cart.containerAttrs→ { id, 'data-wu-app', 'data-wu-ssr', class? }
// cart.state → las props, para renderStateScript

module acepta el módulo ya cargado, una función que lo devuelva, una promesa o un namespace con un default. Así, () => import('./apps/cart/server.js') simplemente funciona y el módulo se queda fuera del grafo hasta que ocurre el render.

const { html, byApp, state, errors } = await renderApps([
{ name: 'header', module: () => import('./apps/header/server.js') },
{ name: 'cart', module: () => import('./apps/cart/server.js'), props: { userId } },
]);

Se renderizan en paralelo. Por defecto, una app que lanza un error no tumba la página: su entrada en byApp es un contenedor vacío <div id="wu-app-cart" data-wu-app="cart"></div> — fíjate en que falta data-wu-ssr, que es justo lo que le dice al cliente que la monte normalmente en vez de hidratarla — y el error aterriza en errors. Pasa { failFast: true } para relanzar el primero en su lugar.

for (const { app, error } of errors) {
log.warn(`SSR failed for ${app}: ${error.message}`, error.cause);
}

Un renderApp que falla lanza un Error con code: 'WU_SSR_RENDER_FAILED', appName y el error original en cause, de modo que un shell puede elegir entre un 500 y degradar a un montaje en cliente.

La hidratación necesita las props con las que renderizó el servidor. renderStateScript las emite:

res.end(template.replace('<!--state-->', renderStateScript(state)));
// <script>window.__WU_SSR_STATE__={"cart":{"userId":42}}</script>

Los valores se serializan con </script> y U+2028/U+2029 escapados, así que una cadena de tus props no puede escaparse del script.

Usa { merge: true } cuando varios componentes independientes emitan cada uno su propio script: cada uno se fusiona con el objeto global en vez de sobrescribirlo. Es lo que hacen las integraciones de Next.js y Astro.

WuAppSSR es un Server Component asíncrono. Llama a renderApp() durante el render de servidor, emite el contenedor con dangerouslySetInnerHTML (React no debe ser dueño de ese subárbol: wu le adjunta un shadow root) y añade un pequeño WuMount con 'use client' que hidrata después de la hidratación de React.

// app/page.jsx — un Server Component, sin 'use client'
import { WuAppSSR } from '@wu-framework/next';
export default function Page() {
return (
<main>
<WuAppSSR
name="cart"
module={() => import('../apps/cart/server.js')}
props={{ userId: 42 }}
/>
</main>
);
}

WuAppSSR.astro acepta las mismas props y funciona tanto en salida SSR como en builds estáticos: en un build estático el marcado se hornea en tiempo de compilación, que es justo lo que quieres para páginas de contenido.

---
import WuAppSSR from '@wu-framework/astro/WuAppSSR.astro';
import cartServer from '../apps/cart/server.js';
---
<WuAppSSR name="cart" module={cartServer} props={{ userId: 42 }} />
Prop Tipo Por defecto Qué hace
name string obligatorio Nombre de la app; debe coincidir con el que registra el cliente
appName string name Solo en Next: sobrescribe el nombre de la app
module module | () => import() obligatorio El módulo de servidor de la app
props object {} Props del render; se publican para la hidratación
styles string | string[] CSS adicional a encapsular
className / class string Clases del contenedor
style Estilo en línea del contenedor
shadowMode 'open' | 'closed' 'open' Solo en Next; closed bloquea la hidratación
hydrate boolean true Con false emite marcado inerte y nunca monta
fallbackToClient boolean true Ante un error de render, deja el hueco y monta en cliente en vez de tumbar la página

WuApp (el componente más antiguo) sigue siendo solo de cliente: renderiza un div vacío y monta después de la hidratación. Sigue usándolo para islas interactivas donde el primer pintado no importa.