مقدمةنظرة عامة
NUCLO / 12

Idioma

Documentación

Documentación de Nuclo

Una referencia práctica para instalar Nuclo, construir con actualizaciones explícitas, dar estilo con CSS tipado y renderizar HTML en el servidor.

Inicio rápidonpm create nuclo@latest
Inicio rápido
v0.3.29
Versión actual
175
Constructores de etiquetas
~18.4 KB
Núcleo, con gzip
Explícito
Modelo de runtime

Visión general

#

Nuclo es una pequeña biblioteca DOM para TypeScript. Construyes la página con funciones simples como div() y button(), guardas el estado en variables ordinarias y llamas a update() cuando la página debe cambiar.

Nuclo no tiene DOM virtual, ni compilador, ni proxies, ni signals.

main.ts
import 'nuclo'

let count = 0

const App = () =>
  div(
    h1(() => `Count: ${count}`),
    button('Add one', { onClick: () => { count++; update() } }),
  )

render(App, document.getElementById('app')!)

El modelo mental

  1. Construye la UI con constructores de etiquetas.
  2. Pasa una función por cada valor que dependa del estado.
  3. Cambia el estado con JavaScript normal.
  4. Llama a update(). Nuclo ejecuta las funciones de nuevo y parchea el DOM.

Cambiar una variable no toca el DOM. La página solo cambia cuando llamas a update().

Inicio rápido

#

create-nuclo genera un proyecto de Vite y TypeScript listo para Nuclo:

$npm create nuclo@latest
$bun create nuclo
$pnpm create nuclo

Pide un nombre de proyecto. Para saltarte la pregunta, pasa el nombre y las opciones. Con npm, pon -- antes de las opciones:

terminal
$ npm create nuclo@latest my-app -- --template basic --yes

Después instala y arranca el servidor de desarrollo:

terminal
$ cd my-app
$ npm install
$ npm run dev

Opciones

  • [project-name]: la carpeta de destino. El valor por defecto es nuclo-app.
  • -t, --template <name>: la plantilla que se copia. Hoy solo existe basic.
  • -y, --yes: nunca pregunta, usa los valores por defecto.
  • -f, --force: escribe en una carpeta que no está vacía.
  • -h, --help: muestra la ayuda.

Instalación e importaciones

#

Para añadir Nuclo a un proyecto existente:

terminal
$ npm install nuclo

O usa pnpm add nuclo, yarn add nuclo o bun add nuclo. Nuclo no tiene dependencias.

El enrutamiento no forma parte del paquete principal. Se distribuye por separado como nuclo-router, que instalas junto a nuclo cuando lo necesites.

Globales

import 'nuclo' registra esto en globalThis:

  • 175 constructores de etiquetas: 112 para HTML (div, span, button, …) y 63 para SVG, con el sufijo Svg (svgSvg, pathSvg, …). Consulta Constructores de etiquetas.
  • 16 helpers: update, scope, when, list, region, into, on, render, hydrate, forceUpdate, css, cx, createCss, variants, keyframes y globalStyle.

La importación debe ejecutarse antes que cualquier código que llame a un constructor. Los módulos ES se ejecutan en orden de importación, así que esto falla:

main.ts
import { App } from './app' // app.ts calls div() → ReferenceError: div is not defined
import 'nuclo'

Pon import 'nuclo' al principio de tu archivo de entrada, o impórtalo en cada archivo que use los globales.

Importaciones con nombre

Los helpers también son exportaciones con nombre. Son las mismas funciones que los globales. getCssText() y resetStyles() solo existen como exportaciones con nombre. Los constructores de etiquetas solo existen como globales, así que import { div } from 'nuclo' no funciona.

main.ts
import { render, update, getCssText } from 'nuclo'

Puntos de entrada

  • nuclo: registra los globales y exporta los 16 helpers más getCssText y resetStyles.
  • nuclo/ssr: renderToString, renderManyToString, renderToStringWithContainer y getCssText. Consulta Renderizado en el servidor.
  • nuclo/polyfill: un DOM pequeño para Node y Bun. Consulta nuclo/polyfill.
  • nuclo/types: solo tipos. Consulta TypeScript.

TypeScript

#

Nuclo incluye sus propios tipos. Necesitas dos cosas:

  • "moduleResolution": "bundler". node16 y nodenext todavía no están soportados.
  • La lib DOM. TypeScript la incluye por defecto cuando no defines lib.

Si cualquier archivo del proyecto importa 'nuclo', todos los archivos ven los tipos globales. No necesitas nada más. Si ningún archivo lo importa, por ejemplo en un tsconfig separado para tests, añade los tipos tú mismo:

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "types": ["nuclo/types"]
  }
}

O añade /// <reference types="nuclo/types" /> a un archivo .d.ts.

Tipos útiles

Estos tipos son globales, así que los usas sin importarlos:

  • NodeModFn<Tag>: lo que devuelve un constructor de etiquetas. Úsalo como tipo de retorno de un componente.
  • NodeModLike<Tag>: cualquier cosa que un constructor de etiquetas acepta como argumento.
  • ExpandedElement<Tag>: lo que devuelven render() y hydrate().
  • ElementTagName: cualquier nombre de etiqueta HTML.
  • ExpandedElementAttributes<Tag>: el objeto de atributos que acepta un constructor de etiquetas, incluidos los manejadores on*, onMount y onDestroy. Úsalo en componentes que reenvían atributos.
  • MountCallback y DestroyCallback: manejadores de onMount y onDestroy.
  • WhenBuilder, ListModifier: lo que devuelven when() y list().
  • RegionOptions, RegionType: las opciones que recibe region().
  • StyleResult, Style, ThemeConfig, CssInstance y los demás tipos de estilos. También puedes importarlos: import type { StyleResult } from 'nuclo'.
badge.ts
function Badge(text: string): NodeModFn<'span'> {
  return span(text)
}

ExpandedElement<Tag> marca cada miembro del DOM como opcional. Haz un cast cuando necesites el tipo completo del elemento: render(App, root) as HTMLDivElement.

Cada etiqueta HTML es una función global con el mismo nombre: div, span, input, … La única excepción es <var>, que es var_ porque var es una palabra reservada. Cada etiqueta SVG lleva el sufijo Svg: svgSvg, pathSvg, circleSvg, …

Llamar a un constructor de etiquetas todavía no crea un elemento. div('Hi') devuelve un builder: una función que crea el elemento más tarde. Nuclo la llama cuando lo montas con render() o lo colocas dentro de otro builder, when() o list(). Cada uso crea un elemento nuevo.

card.ts
let name = 'Ada'

const Card = () =>
  div(
    { id: 'card', className: 'card' },
    h2('Profile'),
    p(() => `Hello, ${name}`),
    button('Rename', { onClick: () => { name = 'Grace'; update() } }),
  )

render(Card, document.body)
En vivoUn constructor recibe atributos, hijos estáticos y texto dinámico. Renombrar cambia el estado y luego llama a update().Abrir ↗

Profile

Hello, Ada

Argumentos

Un builder acepta cualquier número de argumentos y los aplica de izquierda a derecha:

  • string, number, bigint: un nodo de texto.
  • () => value: texto que cambia en update(). Consulta Valores dinámicos.
  • null, undefined: se omiten.
  • true, false: se renderizan como el texto "true" o "false". Usa when() para condiciones.
  • Un objeto plano: atributos. Consulta Atributos.
  • Un resultado de css(), cx() o variants(): añade sus clases.
  • on(...): un listener de eventos o un hook de ciclo de vida.
  • Otro builder, when() o list(): un hijo.
  • Un Node del DOM: se añade tal cual.
  • (el, index) => …: un modificador personalizado. Ver más abajo.

Trampas habituales

  • div(isAdmin && span('Admin')) renderiza el texto "false". Escribe div(when(() => isAdmin, span('Admin'))).
  • Los arrays no son hijos. Expándelos: ul(...names.map((n) => li(n))). Para listas que cambian, usa list().
  • Una variable local con el nombre de una etiqueta oculta el constructor. Dentro de function Row(label: string), label(...) no se puede llamar. Renombra las locales como label, title, data, p, a e i.
  • Los navegadores exponen cada elemento con id como una variable global. Si la página contiene <main id="main"> antes de que Nuclo cargue, Nuclo deja ese global en su sitio, así que main(...) lanza un TypeError. Usa otros ids.

Modificadores personalizados

Una función que declara al menos un parámetro se ejecuta una vez, en orden de argumentos, mientras se construye el elemento. Su primer argumento es el elemento. El segundo es un índice interno, así que no dependas de él. Si devuelve un string o un número, se convierte en texto. Un Node se añade como hijo. Un objeto se aplica como atributos.

example.ts
const field = input((el) => {
  el.autocomplete = 'off'
})

Obtener el elemento

render() devuelve el elemento raíz. Dentro de un árbol, usa on("mount", (el) => …) o un modificador personalizado. También puedes llamar tú mismo a la función builder para crear un elemento desconectado:

example.ts
const el = div('Hello')() as HTMLDivElement
document.body.append(el)

Insértalo antes del siguiente update(). Un nodo que no está en el documento cuando se ejecuta update() deja de actualizarse para siempre.

SVG

Los constructores SVG aceptan los mismos argumentos. Nuclo establece cada atributo SVG con setAttribute, así que nombres como "stroke-width" funcionan tal como se escriben.

icon.ts
const Icon = () =>
  svgSvg(
    { viewBox: '0 0 24 24', width: 24, height: 24, className: 'icon' },
    circleSvg({ cx: 12, cy: 12, r: 10, fill: 'currentColor' }),
  )

Todos los constructores

HTML: a abbr address area article aside audio b base bdi bdo blockquote body br button canvas caption cite code col colgroup data datalist dd del details dfn dialog div dl dt em embed fieldset figcaption figure footer form h1 h2 h3 h4 h5 h6 head header hgroup hr html i iframe img input ins kbd label legend li link main map mark menu meta meter nav noscript object ol optgroup option output p picture pre progress q rp rt ruby s samp script search section select slot small source span strong style sub summary sup table tbody td template textarea tfoot th thead time title tr track u ul var_ video wbr

SVG: aSvg animateSvg animateMotionSvg animateTransformSvg circleSvg clipPathSvg defsSvg descSvg ellipseSvg feBlendSvg feColorMatrixSvg feComponentTransferSvg feCompositeSvg feConvolveMatrixSvg feDiffuseLightingSvg feDisplacementMapSvg feDistantLightSvg feDropShadowSvg feFloodSvg feFuncASvg feFuncBSvg feFuncGSvg feFuncRSvg feGaussianBlurSvg feImageSvg feMergeSvg feMergeNodeSvg feMorphologySvg feOffsetSvg fePointLightSvg feSpecularLightingSvg feSpotLightSvg feTileSvg feTurbulenceSvg filterSvg foreignObjectSvg gSvg imageSvg lineSvg linearGradientSvg markerSvg maskSvg metadataSvg mpathSvg pathSvg patternSvg polygonSvg polylineSvg radialGradientSvg rectSvg scriptSvg setSvg stopSvg styleSvg svgSvg switchSvg symbolSvg textSvg textPathSvg titleSvg tspanSvg useSvg viewSvg

Un componente es una función simple que devuelve un builder, como button(...). No hay una API de componentes. No se crea nada hasta que lo renderizas o lo colocas en un árbol.

counter.ts
function Counter(label: string) {
  let count = 0 // one per call
  return button(() => `${label}: ${count}`, {
    onClick: () => { count++; update() },
  })
}

render(() => div(Counter('A'), Counter('B')))
En vivoUn componente es una función simple. Cada llamada a Counter() conserva su propio conteo.Abrir ↗

El estado declarado dentro de la función pertenece a esa instancia. El estado declarado fuera lo comparten todas las instancias. forceUpdate() llama a los componentes de nuevo, lo que reinicia el estado declarado dentro de ellos.

Los hijos son simplemente argumentos:

card.ts
function Card(heading: string, ...children: NodeModLike<'div'>[]) {
  return div({ className: 'card' }, h2(heading), ...children)
}

render(() => Card('Hi', p('one'), p('two')))

Atributos

#

Pasa un objeto plano para establecer atributos. Puedes pasar varios objetos. Para la misma clave, gana el último valor, salvo className y style, que se combinan. Un valor puede ser estático, o una función sin parámetros que se ejecuta de nuevo en cada update().

example.ts
let busy = false

const save = button(
  { type: 'submit', disabled: () => busy, 'aria-busy': () => String(busy), 'data-id': 42 },
  'Save',
)
En vivoLos valores de los atributos pueden ser funciones. El segundo botón invierte busy y llama a update(); el disabled y el aria-busy de Save lo siguen.Abrir ↗

Cómo se establece un valor

  • Si el elemento HTML tiene una propiedad con ese nombre (value, checked, disabled, htmlFor, tabIndex, …), Nuclo establece la propiedad. De lo contrario llama a setAttribute(key, String(value)), por ejemplo para "aria-*" y "data-*".
  • En elementos SVG, Nuclo siempre llama a setAttribute.
  • null y undefined se omiten. Un valor dinámico que devuelve null o undefined conserva el valor anterior. Para limpiar un valor, devuelve "", o false para una propiedad booleana.
  • Los booleanos funcionan para propiedades booleanas del DOM, como disabled en un botón, checked, hidden y required. En cualquier otro lugar, true y false se escriben como los strings "true" y "false".
  • Usa claves "data-*". dataset no está soportado.
  • innerHTML no se escapa, y el renderizado en el servidor no lo emite. Evítalo.

Clases

Usa className. Los strings de clases estáticos, los resultados de css() y un className dinámico se combinan. Un className dinámico conserva las clases estáticas, y cuando devuelve "" solo quedan las clases estáticas.

example.ts
let active = false
const base = css({ p: 8 })

const item = li(base, { className: 'item' }, { className: () => (active ? 'active' : '') }, 'Home')
// class="<base> item", then "<base> item active" after active = true; update()

No uses la clave class en elementos HTML. Establece el atributo en bruto y reemplaza todas las clases aplicadas antes, incluidas las de css().

Estilos en línea

style acepta un objeto con claves en camelCase, o una función que devuelve uno.

example.ts
let progress = 30

const bar = div({ style: () => ({ width: `${progress}%`, backgroundColor: 'teal' }) })
  • Nuclo no añade unidades. Escribe "12px", no 12. Las propiedades sin unidad, como opacity y zIndex, aceptan números.
  • "", null y undefined limpian una propiedad.
  • Un estilo dinámico solo establece las claves que devuelve. Una clave que deja de devolver conserva su valor anterior, así que devuelve "" para limpiarla.
  • Los strings de estilo ("color: red") y las propiedades personalizadas ("--gap") no están soportados. Usa css() en su lugar. Acepta propiedades personalizadas a través de raw.
fnAPI pública
// DOM events such as "click". El and Ev are inferred from the tag and the event name. function on(type: DOMEventName, listener: (this: El, e: Ev) => unknown, options?: boolean | AddEventListenerOptions): NodeModFn // Custom events function on<K extends string, E extends Event = Event>(type: K, listener: (this: El, e: E) => unknown, options?: boolean | AddEventListenerOptions): NodeModFn // Lifecycle, see Lifecycle function on(type: "mount", listener: MountCallback): NodeModFn function on(type: "destroy", listener: DestroyCallback): NodeModFn

Hay dos formas de escuchar eventos: los atributos on* y el modificador on().

Atributos on*

En un objeto de atributos, una función bajo una clave como onClick (on seguido de una mayúscula) es un manejador de eventos, no un valor dinámico.

search.ts
let query = ''

const search = input({
  placeholder: 'Search',
  value: () => query,
  onInput: (e) => { query = e.currentTarget.value; update() },
  onKeyDown: (e) => { if (e.key === 'Escape') { query = ''; update() } },
})
  • TypeScript infiere el tipo del evento a partir de la clave y del elemento. e.currentTarget y this son el elemento.
  • Nuclo establece la propiedad nativa, así que onClick se convierte en el.onclick. Hay un manejador por evento: un onClick posterior reemplaza al anterior.
  • onDoubleClick y onDblClick significan ambos dblclick. Las claves en minúsculas como onclick son errores de tipo.
  • Un nombre que no es un evento conocido escucha su forma en minúsculas: onMyEvent escucha "myevent".
  • Los errores que lanza el manejador no se capturan.
En vivoonInput y onKeyDown son atributos simples. Escribe y luego pulsa Escape para limpiar.Abrir ↗

Type something, then press Escape.

on()

on() devuelve un modificador que llama a addEventListener en el elemento.

panel.ts
const panel = div(
  on('scroll', onScroll, { passive: true }),
  on('click', logClick),
  on('click', closeMenu),
)
  • Cada on() añade un listener. Todos se ejecutan, en orden.
  • options va directo a addEventListener: capture, once, passive, signal.
  • Pasa on() directamente a un builder y el tipo del elemento se infiere: input(on('input', (e) => e.currentTarget.value)).
  • Si un listener lanza un error, Nuclo lo registra con console.error, y los demás listeners se ejecutan igualmente.
  • Nuclo quita el listener cuando quita el elemento. En el servidor, on() no hace nada.
  • Puedes reutilizar un mismo valor de on() en muchos elementos.

Para un evento personalizado, pasa el tipo del evento como genérico:

picker.ts
type PickEvent = CustomEvent<{ id: number }>

const picker = ul(on<'pick', PickEvent>('pick', (e) => choose(e.detail.id)))

Cuál usar

Usa los atributos on* para el caso habitual. Usa on() cuando necesites más de un listener para un evento, opciones de listener, o un nombre de evento personalizado como "my-event".

fnAPI pública
// As attributes { onMount?: (el: El) => void | (() => void), onDestroy?: (el: El) => void } // Or with on() function on(type: "mount", listener: (el: El) => void | (() => void)): NodeModFn function on(type: "destroy", listener: (el: El) => void): NodeModFn

onMount se ejecuta una vez, después de insertar el elemento. onDestroy se ejecuta una vez, cuando Nuclo quita el elemento. Escríbelos como atributos o con on(). Ambas formas se comportan igual, y el elemento se tipa según su etiqueta.

timer.ts
let seconds = 0

const Timer = () =>
  span(
    {
      onMount: () => {
        const id = setInterval(() => { seconds++; update() }, 1000)
        return () => clearInterval(id) // runs when the span is removed
      },
    },
    () => `${seconds}s`,
  )

const search = input(on('mount', (el) => el.focus()))
En vivoonMount inicia un intervalo y devuelve su limpieza. Desmontar ejecuta la limpieza, así que el conteo se pausa hasta que el temporizador se monta de nuevo.Abrir ↗

Cuándo se ejecutan

  • onMount se ejecuta al final de la llamada a render(), hydrate(), update() o forceUpdate() que insertó el elemento. Nunca se ejecuta mientras se construye el árbol.
  • Si onMount devuelve una función, esa función se ejecuta al destruir, después de los callbacks onDestroy del elemento.
  • onDestroy se ejecuta cuando Nuclo quita el elemento: se elimina una fila de list(), cambia una rama de when(), un into() abandona su región, o forceUpdate() lo descarta. Se ejecuta justo antes de la eliminación, así que el elemento todavía está en el documento.

Eliminación directa del DOM

Si quitas un elemento tú mismo, con node.remove() o innerHTML = '', su onDestroy y sus limpiezas nunca se ejecutan, y los temporizadores siguen corriendo. Nuclo no tiene unmount(). Para desmontar una app con limpieza, envuélvela en when():

main.ts
let mounted = true
render(() => div(when(() => mounted, App())), root)

mounted = false
update() // App's onDestroy hooks and cleanups run

Orden y errores

  • Puedes registrar tantos hooks como quieras, en cualquiera de las dos formas. Se ejecutan en orden de registro.
  • onDestroy siempre ejecuta primero los hijos y después el padre.
  • Los elementos se montan en el orden de su primer hook onMount, que sigue el orden de los argumentos, y cada elemento ejecuta todos sus hooks juntos. Pasa el hook de un padre antes que sus hijos si estos dependen de él.
  • Un hook que lanza un error se registra con console.error. Los demás hooks se ejecutan igualmente.
  • El callback debe devolver nada o una función de limpieza, así que no lo hagas async. Una Promise devuelta nunca se usa como limpieza. Inicia el trabajo asíncrono dentro: { onMount: (el) => { void load(el) } }.
  • Los hooks nunca se ejecutan en el servidor. En el cliente, se ejecutan después de hydrate().
Nota: los hooks no son efectos. Nunca se ejecutan de nuevo cuando cambia el estado. Cada uno se ejecuta como máximo una vez por elemento.

Actualizaciones explícitas

#

Nuclo nunca actualiza la página por su cuenta. Cambia tu estado y después llama a update().

counter.ts
let count = 0

const Counter = () =>
  button(() => `Clicked ${count} times`, {
    onClick: () => { count++; update() },
  })

render(Counter)
En vivocount++ solo cambia la variable. El texto se pone al día en el siguiente update(), desde el botón o desde cualquier otra cosa en esta página.Abrir ↗

Clicked 0 times

Cambia todo el estado que quieras y después llama a update() una sola vez:

example.ts
let first = 'Ada'
let last = 'Lovelace'

render(() => p(() => `${first} ${last}`))

first = 'Grace'
last = 'Hopper'
update() // one pass, both changes

Llama a update() desde manejadores de eventos, temporizadores, callbacks de fetch u onMount. No lo llames desde dentro de un valor dinámico.

Valores dinámicos

#

Una función sin parámetros es un valor dinámico. Nuclo la ejecuta una vez cuando construye el elemento, y de nuevo en cada update(). Usa valores dinámicos para hijos de texto, valores de atributos, style y className. Para hijos que aparecen, desaparecen o se repiten, usa when() y list().

example.ts
let name = 'Alice'
let busy = false

const Profile = () =>
  div(
    h1('Welcome'),              // static
    p(() => `Hello, ${name}`),  // dynamic text
    button({ disabled: () => busy }, 'Save'),
  )
En vivoEdita el nombre. El texto estático quedó fijado cuando se construyó el elemento; el dinámico se ejecuta de nuevo en cada update(). forceUpdate() reconstruye el árbol, así que el texto estático también se pone al día.Abrir ↗

static: Hello, Alice

dynamic: Hello, Alice

Nuclo comprueba el número de parámetros (fn.length). Una función que declara un parámetro es un modificador personalizado y se ejecuta solo una vez. (x = 0) => … y (...args) => … tienen una longitud de 0, así que son dinámicas.

Texto dinámico

  • Un string, number, bigint o boolean se renderiza como String(value). () => ok && 'Saved' muestra "false", así que escribe () => (ok ? 'Saved' : '').
  • null y undefined se renderizan como texto vacío.
  • Un resultado de css() o cx() en la primera ejecución convierte la función en una clase dinámica, no en texto. Consulta cx().
  • Un nodo, builder, array u objeto plano no renderiza nada, y nunca se actualiza.

Atributos dinámicos

  • Nuclo escribe un valor solo cuando difiere del último valor que escribió. No lo compara con el DOM real, así que si un usuario escribe en un input y tu función devuelve el mismo valor que antes, el texto que escribió se conserva.
  • Un resultado de null o undefined no escribe nada, así que el valor anterior se conserva. className es la excepción: cualquier resultado falsy quita las clases dinámicas y conserva las estáticas.
  • Una función style aplica el objeto devuelto en cada actualización.

Errores

Si un valor dinámico lanza un error, Nuclo lo captura y el resto de la actualización se ejecuta igualmente. El texto queda vacío o conserva su valor anterior, y un atributo conserva su valor anterior. Ambos se recuperan en el siguiente update() que tenga éxito. La excepción: una función de texto que lanza un error en su primera ejecución queda vacía para siempre.

fnAPI pública
function update(...scopeIds: string[]): void

Ejecuta una pasada sincrónica sobre cada valor dinámico de la página. Con ids, solo actualiza lo que está dentro de los elementos marcados con esos ids de scope(). Cada pasada ejecuta estos pasos en orden:

  1. list(): lee los elementos de nuevo y añade, mueve o quita filas.
  2. when(): comprueba las condiciones de nuevo. Una rama se reconstruye solo cuando otra rama distinta pasa a estar activa.
  3. Atributos dinámicos, incluidos style y className.
  4. Texto dinámico.
  5. onMount para los elementos que insertaron los pasos 1 y 2.
  • Ejecuta todo. Cada valor dinámico se ejecuta en cada llamada, incluso cuando sus datos no cambiaron. Solo se omite la escritura en el DOM. Mantén los valores dinámicos rápidos y sin efectos secundarios.
  • Solo nodos en el documento. Si un nodo no está en el documento cuando se ejecuta update(), Nuclo deja de seguirlo. Nunca vuelve a actualizarse, ni siquiera después de insertarlo. Esto también aplica a list() y when(). Renderiza en un contenedor que esté en el documento.
  • Los valores estáticos nunca cambian. Los strings simples y los valores de atributos quedan fijados cuando se construye el elemento. Para reconstruirlos, usa forceUpdate().
  • Errores. Los errores de texto y atributos se capturan, y la pasada continúa. Si una condición de when() lanza un error, el error se registra y ese when() deja de actualizarse. Si una función items o renderItem de list() lanza un error, el error escapa de update() y los pasos restantes no se ejecutan.
  • Servidor. update() no hace nada en el servidor.
En vivoCada update() ejecuta todos los valores dinámicos, incluso cuando sus datos no cambiaron. Otras demos de esta página también suben el conteo.Abrir ↗

This dynamic value has run 1 times.

fnAPI pública
function scope(...ids: string[]): NodeModFn

Da uno o más ids de ámbito al elemento al que se pasa. update('id') entonces actualiza solo lo que está dentro de ese elemento, incluidos los atributos del propio elemento.

example.ts
let cartItems: string[] = []
let user = 'Ada'

const App = () =>
  div(
    header(() => `Hi, ${user}`),
    aside(scope('cart'), span(() => `Items: ${cartItems.length}`)),
  )

render(App)

cartItems.push('apple')
user = 'Grace'
update('cart') // "Items: 1", the header still says "Hi, Ada"
update()       // everything, the header now says "Hi, Grace"
En vivoAmbos botones añaden un elemento y renombran al usuario. update('cart') refresca solo el panel con scope.Abrir ↗
Hi, Ada
Items: 0
  • Un elemento puede tener varios ids: scope('cart', 'sidebar').
  • update('a', 'b') actualiza todo lo que está dentro de a o b. Si varios elementos usan el mismo id, todos se actualizan.
  • Con ámbitos anidados, update('outer') incluye el ámbito interior. update('inner') no toca el exterior.
  • Un id que ningún elemento del documento tiene no actualiza nada y no lanza ningún error, así que revisa la ortografía.
  • scope() no hace nada en el servidor.
fnAPI pública
function when(condition: boolean | (() => boolean), ...content: NodeModLike[]): WhenBuilder interface WhenBuilder { when(condition: boolean | (() => boolean), ...content: NodeModLike[]): WhenBuilder else(...content: NodeModLike[]): WhenBuilder }

Renderiza contenido según una condición. Nuclo comprueba las condiciones de nuevo en cada update(). Gana la primera condición verdadera. Si ninguna es verdadera, se renderiza el contenido de .else(). Sin .else(), no se renderiza nada.

status.ts
let status: 'loading' | 'error' | 'ready' = 'loading'

const Status = () =>
  div(
    when(() => status === 'loading', p('Loading…'))
      .when(() => status === 'error', p('Something went wrong'))
      .else(p('Ready')),
  )
En vivoLa primera condición verdadera gana; de lo contrario, se renderiza .else(). Una rama nueva se construye desde cero.Abrir ↗

Loading…

Condiciones

  • Para estado, pasa una función. Un booleano simple se lee una sola vez: when(open, …) nunca cambia.
  • En TypeScript la función debe devolver un booleano. Escribe () => items.length > 0 o () => user != null.

Qué se actualiza

  • Mientras la misma rama sigue activa, sus elementos se conservan. Los valores dinámicos, las listas y los bloques when() dentro de ella siguen actualizándose.
  • Cuando otra rama pasa a estar activa, el contenido anterior se quita y se ejecutan sus hooks onDestroy. La nueva rama se construye desde cero y se ejecutan sus hooks onMount. El estado de los elementos anteriores, como el texto de un input o el foco, se pierde.

Contenido de una rama

Pon hijos en una rama: texto, texto dinámico, builders, list() y when() anidados. Pasa builders como p('Hi'), no nodos del DOM: un nodo del DOM deja de actualizarse después de que su rama se oculte una vez.

Los objetos de atributos y on() en una rama se aplican al elemento padre, y permanecen después de que la rama se oculte. Para un atributo condicional, usa un atributo dinámico: { className: () => (active ? 'active' : '') }.

Encadenamiento

.when() y .else() devuelven un builder nuevo y no cambian el original. Si llamas a .else() dos veces, gana la última llamada.

fnAPI pública
function list<T>( items: () => readonly T[] | Iterable<T>, renderItem: (item: T, index: number) => ListRenderResult, ): ListModifier

Renderiza una fila por elemento. En cada update(), Nuclo llama a items() de nuevo y empareja los nuevos elementos con las filas existentes por identidad (===).

fruits.ts
let fruits = ['Apple', 'Banana']

const Fruits = () => ul(list(() => fruits, (fruit) => li(fruit)))

render(Fruits)

fruits.push('Cherry')
update()

Cómo se emparejan las filas

  • Un elemento que sigue presente conserva su fila. Nuclo mueve la fila si hace falta, y no llama a renderItem de nuevo.
  • Un elemento nuevo recibe una fila nueva. Un elemento eliminado pierde su fila, y se ejecutan sus hooks onDestroy.
  • Los elementos duplicados reciben cada uno su propia fila.
  • Puedes modificar el array en el sitio (push, splice, sort) o devolver un array nuevo.

Una fila conservada nunca se renderiza de nuevo, así que usa valores dinámicos para los campos que cambian:

todos.ts
const todos = [{ text: 'Write docs', done: false }]

const Todos = () =>
  ul(
    list(() => todos, (todo) =>
      li({ className: () => (todo.done ? 'done' : '') }, () => todo.text),
    ),
  )

render(Todos)

todos[0].done = true
update() // same <li>, its class is now "done"

Las copias son elementos nuevos, así que reemplazar el array con todos.map((t) => ({ ...t })) reconstruye todas las filas.

En vivoLas filas se emparejan por identidad. Marca una fila y luego pulsa Reverse: el mismo <li> se mueve, con su checkbox y todo.Abrir ↗

Qué devuelve renderItem

  • Devuelve un elemento, como li(...), tr(...) o un builder SVG. Devuelve null o undefined para omitir un elemento. Los strings, los nodos de texto y los fragmentos no renderizan nada.
  • No devuelvas when() ni list() directamente. Ponlos dentro del elemento de la fila: li(todo.text, when(() => todo.done, ' ✓')).
  • Para ocultar elementos, filtra en items(), no en renderItem.
  • Mantén renderItem sin efectos secundarios. Nuclo puede llamarlo más de una vez para el mismo elemento.

El índice

index es la posición del elemento cuando se creó su fila. No se actualiza cuando las filas se mueven. Para la posición actual, usa un valor dinámico: li(() => `${names.indexOf(name) + 1}. ${name}`).

Iterables

Cualquier iterable funciona: arrays, Set, generadores y map.values(). Devolver el mismo array o Set cada vez está bien. Un iterador, como un generador o map.values(), se agota tras una pasada, así que créalo dentro de items() en cada llamada. Iterar un Map directamente crea arrays [key, value] nuevos cada vez, así que todas las filas se reconstruyen. Usa map.values() en su lugar.

Ubicación

Las filas aparecen donde list() está entre sus hermanos, entre dos marcadores de comentario. Los demás hijos del padre no se tocan. list() también funciona dentro de builders SVG.

region() e into()

#
fnAPI pública
function region(options: RegionOptions): NodeModFn function into(id: string, ...content: NodeModLike[]): NodeModFn function into(regions: Record<string, NodeModLike | NodeModLike[]>): NodeModFn interface RegionOptions { id: string type?: 'latest' | 'stack' // default: 'latest' empty?: NodeModLike }

region() marca un lugar con nombre en el árbol. into() lo rellena desde cualquier otro sitio, a cualquier profundidad. El layout no recibe el contenido como prop, y el componente que construye el contenido no sabe dónde acaba.

app.ts
const Layout = () =>
  div(
    aside(nav(a({ href: '/' }, 'Home'))),
    main(region({ id: 'main', empty: p('Nothing open.') })),
  )

// Anywhere in the tree, before or after the region
const Article = () => into('main', article(h1('Hello')))

render(() => div(Layout(), Article()))

Cómo funciona

  • into() deja solo un comentario donde se escribe. Su contenido se renderiza entre los marcadores de la región.
  • El orden no importa. Un into() construido antes que su región espera y se muestra en el momento en que se construye la región. Las vistas se muestran en el orden en que se escribieron.
  • El contenido vive tanto como la vista. Cuando el when() que contiene un into() se cierra, o se quita su fila de list(), el contenido abandona la región. Abrirlo de nuevo añade una copia, no dos.
  • Cuando una región desaparece, por ejemplo porque su when() se cierra, sus vistas vuelven a esperar. Una región construida más tarde con el mismo id las muestra.
  • Un into() cuyo id no coincide con ninguna región no renderiza nada y no lanza ningún error, así que revisa la ortografía.
  • Los ids son únicos en el árbol. Una segunda región construida con un id ya activo registra una advertencia.
  • Las regiones funcionan con el renderizado en el servidor. En hydrate(), cada vista reclama el HTML que el servidor renderizó en lugar de construirlo de nuevo.

Opciones

  • id: el nombre que las llamadas a into() usan para encontrar la región.
  • type: 'latest' (el valor por defecto) muestra solo la vista más reciente. 'stack' muestra todas las vistas, en orden de llegada.
  • empty: se renderiza mientras la región no contiene ninguna vista.

Regiones apiladas

Con type: 'stack', una vista que se abre sobre otra no reconstruye la que está debajo. Su DOM, su foco y el estado de sus formularios se conservan. Úsalo para una capa como un diálogo sobre una página.

layers.ts
let open = false

const App = () =>
  div(
    region({ id: 'main', type: 'stack' }),
    into('main', form(input({ placeholder: 'Keeps its text' }))),
    when(() => open, into('main', div('On top'))),
  )

Varias regiones a la vez

Pasa un objeto para rellenar más de una región desde un único into(). Un valor puede ser un hijo o un array de hijos. Ambas formas se anidan: un into() dentro del contenido de otra vista apunta a su propia región.

page.ts
const Page = () =>
  into({
    main: article(h1('Eager route')),
    sidebar: [h2('Related'), ul(li('One'), li('Two'))],
  })

Un diálogo abierto desde lo profundo del árbol

La región de overlay está en la raíz. Cualquier componente, a cualquier profundidad, abre un diálogo envolviendo un into() en un when(). Cerrar el when() quita el diálogo de la región.

dialog.ts
let confirming = false

const App = () =>
  div(
    Page(),
    div({ className: 'overlay' }, region({ id: 'overlay' })),
  )

// Deep inside Page()
const DeleteButton = () =>
  div(
    button('Delete', { onClick: () => { confirming = true; update() } }),
    when(() => confirming,
      into('overlay',
        dialog({ open: true },
          p('Delete this item?'),
          button('Cancel', { onClick: () => { confirming = false; update() } }),
        ),
      ),
    ),
  )

Toasts desde filas de una lista

Cada fila de un list() es una vista. Una región apilada las muestra en orden, y quitar un elemento del array quita su toast.

toasts.ts
const toasts: { text: string }[] = []

const notify = (text: string) => {
  toasts.push({ text })
  update()
}

const dismiss = (toast: { text: string }) => {
  toasts.splice(toasts.indexOf(toast), 1)
  update()
}

const App = () =>
  div(
    button('Save', { onClick: () => notify('Saved.') }),
    div({ className: 'toasts' }, region({ id: 'toasts', type: 'stack' })),
    list(() => toasts, (toast) =>
      into('toasts', div(toast.text, button('×', { onClick: () => dismiss(toast) }))),
    ),
  )

Un título de página definido por la propia página

Con el type: 'latest' por defecto, gana la vista más reciente. El layout es dueño del encabezado, y la página que esté abierta le pone nombre. Una página sin into('title', …) deja el texto de empty.

title.ts
let page: 'home' | 'settings' = 'home'

const Layout = () =>
  div(
    h1(region({ id: 'title', empty: 'My app' })),
    when(() => page === 'home', Home()).else(Settings()),
  )

const Home = () => p('Welcome.')

const Settings = () =>
  div(
    into('title', 'Settings'),
    form(/* … */),
  )

Valores dinámicos dentro de una vista

El contenido de un into() es contenido Nuclo ordinario. Las funciones dentro de él son valores que dependen del estado y se refrescan en update(), esté donde esté la región.

status.ts
const selected = new Set<string>()

const Layout = () =>
  div(
    header(region({ id: 'status' })),
    Table(),
  )

const Table = () =>
  div(
    into('status', () => `${selected.size} selected`),
    list(() => rows, (row) =>
      label(
        input({ type: 'checkbox', onChange: (e) => {
          e.currentTarget.checked ? selected.add(row.id) : selected.delete(row.id)
          update()
        } }),
        row.name,
      ),
    ),
  )

Una región que aparece y desaparece

El into() del panel se escribe una sola vez. Mientras el usuario no ha iniciado sesión no hay región main, así que la vista espera. Iniciar sesión construye la región y la vista se muestra. Cerrar sesión quita la región y la vista vuelve a esperar.

auth.ts
let user: { name: string } | null = null

const App = () =>
  div(
    when(() => user !== null,
      main(region({ id: 'main' })),
    ).else(
      LoginForm(),
    ),
    into('main', Dashboard()),
  )

Una vista con su propia región

Una vista puede contener un region(), y otras vistas pueden apuntar a él. Aquí la página del editor trae consigo una región tabs, y cada pestaña la rellena. Cuando el editor se cierra, su región se va con él.

editor.ts
let tab: 'code' | 'preview' = 'code'

const Editor = () =>
  into('main',
    div(
      nav(
        button('Code', { onClick: () => { tab = 'code'; update() } }),
        button('Preview', { onClick: () => { tab = 'preview'; update() } }),
      ),
      section(region({ id: 'tabs' })),
    ),
    when(() => tab === 'code', into('tabs', textarea()))
      .else(into('tabs', iframe({ src: '/preview' }))),
  )

Ejemplo en vivo

region-app.ts
import 'nuclo'

let page: 'home' | 'article' | null = 'home'
let dialog = false

const go = (next: typeof page) => () => {
  page = next
  dialog = false
  update()
}

const Layout = () =>
  div(
    nav(
      button('Home', { onClick: go('home') }),
      button('Article', { onClick: go('article') }),
      button('Close page', { onClick: go(null) }),
    ),
    main(
      region({ id: 'main', type: 'stack', empty: p('Nothing open.') }),
    ),
    aside(region({ id: 'side', empty: p('No related links.') })),
  )

// Pages fill the layout's regions from outside it.
const Home = () =>
  into('main',
    input({ placeholder: 'Type here, then open the dialog' }),
    button('Open dialog', {
      onClick: () => { dialog = true; update() },
    }),
  )

const Article = () =>
  into({
    main: article(h2('Article'), p('One into() fills both regions.')),
    side: [h2('Related'), ul(li('One'), li('Two'))],
  })

// Stacks over Home, so Home keeps its DOM and the text you typed.
const Dialog = () =>
  into('main',
    div(
      p('On top of Home.'),
      button('Close', { onClick: () => { dialog = false; update() } }),
    ),
  )

export const RegionApp = () =>
  div(
    Layout(),
    when(() => page === 'home', Home()),
    when(() => page === 'article', Article()),
    when(() => dialog, Dialog()),
  )
En vivoUn layout con una región principal apilada y una región lateral, llenadas desde fuera. Escribe en Home, abre el diálogo y ciérralo: el texto permanece. Article llena ambas regiones con un solo into().Abrir ↗

No related links.

fnAPI pública
function forceUpdate(): void function forceUpdate(nodeModFn: NodeModFn | (() => NodeModFn), parent?: Element): ExpandedElement

update() solo ejecuta valores dinámicos. Los valores estáticos, como el texto en h1(labels[lang].title), quedan fijados cuando se construye el árbol. forceUpdate() llama a tus componentes de nuevo y parchea el DOM real para que coincida, valores estáticos incluidos. Úsalo para cambios poco frecuentes que afectan a toda la app, como cambiar el idioma. Usa update() para todo lo demás.

i18n.ts
const labels = { en: { title: 'Hello' }, pt: { title: 'Olá' } }
let lang: keyof typeof labels = 'en'

const App = () => div(h1(labels[lang].title))
render(App, root)

lang = 'pt'
forceUpdate() // same <h1> element, now "Olá"
En vivoupdate() refresca solo la línea dinámica de lang. forceUpdate() llama a App de nuevo y parchea el mismo <h1>. La forma explícita reconstruye solo este árbol, no toda la página.Abrir ↗

lang = 'en'

Hello

Static text, fixed on build.

Sin argumentos, forceUpdate() reconstruye cada raíz renderizada con render(App) o hydrate(App). Las raíces renderizadas con render(App()) se omiten. Si un componente lanza un error, el error se registra y esa raíz conserva su DOM anterior. Un error lanzado más tarde, mientras se aplica la nueva construcción, también se registra, pero las partes ya parcheadas siguen parcheadas.

Qué se conserva y qué cambia

  • Los hijos se emparejan en orden, por etiqueta. Un elemento que coincide se reutiliza, así que el foco y los valores escritos en los inputs se conservan, y sus hooks onMount no se ejecutan de nuevo. En cuanto la etiqueta de un hijo difiere, ese hijo y todos los hermanos posteriores se construyen desde cero.
  • El texto, los atributos, las clases, los estilos, los listeners, las ramas de when() y las filas de list() siguen la nueva construcción.
  • Los elementos nuevos se crean, y se ejecutan sus hooks onMount. Los elementos que la nueva construcción ya no genera se quitan, y se ejecutan sus hooks onDestroy.
  • Las filas de list() también se emparejan por posición, no por elemento, así que un elemento de fila reutilizado puede acabar mostrando un elemento distinto.
  • Los atributos que la nueva construcción ya no establece no se quitan.
Cuidado: el estado declarado dentro de una función de componente se reinicia, porque la función se ejecuta de nuevo. Mantén fuera del componente el estado que deba sobrevivir.

Forma explícita. forceUpdate(App(), parent) reconstruye un árbol en parent y devuelve su raíz. No registra la raíz para llamadas posteriores. Como hydrate(), espera que la raíz sea el primer nodo de parent. Si el primer nodo tiene otra etiqueta, se reemplaza.

En el servidor, forceUpdate() no hace nada.

render()

#
fnAPI pública
function render(nodeModFn: NodeModFn | (() => NodeModFn), parent?: Element, index?: number): ExpandedElement

Construye tu árbol y añade su elemento raíz a parent. parent es document.body por defecto. Devuelve el elemento raíz.

main.ts
import 'nuclo'
import { App } from './app.ts'

render(App, document.getElementById('app')!)
  • Puedes pasar el componente (render(App)) o el builder que devuelve (render(App())). El DOM es el mismo. Solo render(App) permite que forceUpdate() reconstruya la raíz más tarde, así que es la forma preferible.
  • Añade al final. No vacía parent, así que llamarlo dos veces añade dos copias.
  • Todos los onMount del árbol se han ejecutado cuando render() devuelve.
  • Renderiza en un elemento que esté en el documento. Consulta update().
  • La raíz debe ser un elemento, como div(...), no when() ni list().
  • index es interno. No decide dónde se inserta el elemento. Omítelo.

Un componente pasado como render(App) no debe recibir parámetros. Un parámetro obligatorio es un error de tipo. Uno opcional pasa la comprobación de tipos, pero render() lanza un error en tiempo de ejecución. Envuelve un componente que recibe props:

main.ts
const Page = (props: { name: string }) => div(h1('Hi ', props.name))

render(() => Page({ name: 'Ana' }), root)

hydrate()

#
fnAPI pública
function hydrate(nodeModFn: NodeModFn | (() => NodeModFn), parent?: Element): ExpandedElement

Toma el control del HTML generado por renderToString(). Reutiliza los elementos existentes, adjunta los listeners de eventos y conecta los valores dinámicos, when() y list(), para que update() funcione después. Luego ejecuta los hooks onMount. parent es document.body por defecto. Devuelve el elemento raíz.

client.ts
import 'nuclo'
import { App } from './app.ts'

hydrate(App, document.getElementById('root')!)
  • Usa el mismo componente, y los mismos datos, que el servidor.
  • Hidrata en un contenedor dedicado. La raíz de la app debe ser su primer nodo, aunque los espacios en blanco no importan. Si otro nodo va primero, Nuclo reemplaza ese nodo y deja atrás la copia del servidor.
  • Como con render(), pasa App, no App(), para que forceUpdate() pueda reconstruir la raíz más tarde. App no debe recibir parámetros. Con uno opcional, hydrate() no adjunta nada y no avisa. Envuelve un componente que recibe props: hydrate(() => Page(props), root).
  • Si no hay HTML del servidor, usa render().

Cuando el servidor y el cliente difieren

Gana el cliente:

  • Una etiqueta raíz distinta: ese nodo se reemplaza con DOM nuevo. Un contenedor vacío: se añade DOM nuevo.
  • El texto distinto se parchea. Los elementos de list() y las ramas de when() distintos siguen al cliente.
  • Los hijos sobrantes del servidor se quitan, y los que faltan se crean.
  • Los atributos que el servidor escribió pero el cliente no establece se dejan como están.

renderToString() escribe comentarios HTML como marcadores, por ejemplo <!-- text-0 --> antes de cada nodo de texto. hydrate() los necesita, así que no elimines los comentarios del HTML del servidor. Sin los marcadores, la hidratación borra las filas de list(), y list() y when() dejan de actualizarse.

Renderizado en el servidor

#
fnAPI pública
// import { ... } from 'nuclo/ssr' function renderToString(input: RenderableInput): string function renderManyToString(inputs: RenderableInput[]): string[] function renderToStringWithContainer(input: RenderableInput, containerTag?: string, containerAttrs?: Record<string, string>): string function getCssText(): string type RenderableInput = NodeModFn | (() => NodeModFn) | Element | Node | null | undefined

Renderiza un componente a un string HTML en el servidor. En el navegador, llama a hydrate() con el mismo componente para hacer ese HTML interactivo. Los runtimes de servidor no tienen document, así que importa primero nuclo/polyfill.

app.ts
let count = 0
const heading = css({ color: 'red' })

export const App = () =>
  main(
    h1(heading, () => `Count: ${count}`),
    button({ onClick: () => { count++; update() } }, '+1'),
  )
server.ts
import 'nuclo/polyfill'
import 'nuclo'
import { renderToString, getCssText } from 'nuclo/ssr'
import { App } from './app.ts'

const html = renderToString(App)
const page = `<!doctype html>
<html>
  <head><style id="nuclo-styles">${getCssText()}</style></head>
  <body>
    <div id="root">${html}</div>
    <script type="module" src="/client.js"></script>
  </body>
</html>`
client.ts
import 'nuclo'
import { App } from './app.ts'

hydrate(App, document.getElementById('root')!)

Mantén id="nuclo-styles" en la etiqueta style. El cliente reutiliza ese elemento y añade solo las reglas nuevas. Consulta getCssText().

Qué se ejecuta en el servidor

  • Cada llamada a renderToString() construye el árbol y ejecuta cada valor dinámico una vez.
  • Los manejadores de eventos, onMount y onDestroy nunca se ejecutan. update(), scope() y forceUpdate() no hacen nada.
  • El renderizado es sincrónico. Carga primero tus datos y después renderiza.
  • Nada queda registrado después de un renderizado, así que un proceso puede servir muchas peticiones. Pasa los datos de la petición como argumentos, por ejemplo renderToString(Page(user)). Las variables a nivel de módulo las comparten todas las peticiones.
  • No llames a render() en el servidor. Añade contenido al document.body compartido.

renderToString()

Acepta un builder (App()), un componente (App) o un nodo del DOM. null y undefined devuelven "". La raíz debe ser un elemento. Un componente pasado como renderToString(Page) no debe recibir parámetros, así que pasa Page(user) para uno que recibe datos.

example.ts
renderToString(p('Hello'))
// '<p><!-- text-0 -->Hello</p>'
  • El texto y los valores de atributos se escapan como HTML. El texto dentro de script() y style() se escribe tal cual, solo con las etiquetas de cierre partidas, así que nunca pongas ahí entrada del usuario.
  • No lanza errores. Si el componente, una función de list() o una condición de when() lanza un error, o falta el polyfill, registra el error y devuelve "". Comprueba si el resultado está vacío cuando la petición deba fallar. Una función de texto o de atributo que lanza un error solo se renderiza vacía.

renderManyToString()

Lo mismo que inputs.map(renderToString).

renderToStringWithContainer()

Envuelve la salida en un elemento. containerTag es "div" por defecto, y containerAttrs es {}. Los valores de atributos se escapan. Los nombres de atributos se escriben tal como se dan, así que usa nombres HTML como class. Nunca pases entrada del usuario como etiqueta.

example.ts
renderToStringWithContainer(p('Hi'), 'section', { id: 'main', class: 'page' })
// '<section id="main" class="page"><p><!-- text-0 -->Hi</p></section>'

nuclo/polyfill

#
import 'nuclo/polyfill' // Named exports: // document, Event, CustomEvent, Node, Element, HTMLElement, // NucloDocument, NucloElement, NucloText, NucloNode

Un DOM pequeño para que Nuclo pueda construir árboles en Node y Bun. Impórtalo una vez, antes de tu primer renderizado. La mayoría de las apps solo necesitan la importación simple.

  • Cuando no hay window, define globalThis.document, Node, Element y HTMLElement, pero solo los que faltan. No crea window.
  • En un navegador, o bajo jsdom, no hace nada.
  • Event y CustomEvent son los constructores propios del runtime. Node, Element y HTMLElement son alias de las clases del polyfill.

Es un DOM para construir árboles, no un navegador. No tiene innerHTML ni getElementById, querySelector() devuelve null, y los listeners de eventos se ignoran. Usa renderToString() para obtener el HTML.

css()

#
fnAPI pública
function css(style: Style): StyleResult function css(name: string, style: Style): StyleResult type StyleResult = { className: string }

Convierte un objeto de estilo tipado en clases CSS generadas. Pasa el resultado a cualquier constructor de etiquetas. En el navegador, las reglas van a un único elemento <style id="nuclo-styles">. En el servidor, getCssText() las devuelve.

card.ts
const card = css({
  p: 16,
  rounded: 8,
  bg: '#fff7ed',
  hover: { bg: '#ffedd5' },
})

const Card = () => div(card, 'Simple card')
  • css() crea una clase para las declaraciones base, más una clase por cada clave pseudo, selector y bloque media. La tarjeta de arriba recibe dos clases.
  • Los nombres de clase son hashes del contenido. Estilos idénticos comparten clase, y el servidor y el cliente producen los mismos nombres.
  • Define los estilos una vez, a nivel de módulo. css() cachea por objeto, así que un objeto nuevo en cada llamada se procesa de nuevo cada vez.
  • Cambiar un objeto de estilo después de pasarlo a css() no tiene efecto.
  • Cada estilo queda registrado durante toda la vida de la página o del proceso del servidor. Para valores que cambian por elemento, como un ancho que viene de los datos, usa el atributo style.

Estilos con nombre

Pasa primero un nombre para obtener un nombre de clase legible:

title.ts
const pageTitle = css('page-title', { text: 24, hover: { color: 'red' } })

pageTitle.className // "page-title page-title-<hash>"
  • La clase base es exactamente el nombre. Los demás bloques reciben name-<hash>.
  • El nombre debe ser un nombre de clase válido. Si no, Nuclo avisa y genera uno.
  • Los nombres son globales. Si dos estilos distintos usan el mismo nombre, Nuclo avisa.

Usar el resultado

example.ts
const card = css({ p: 8 })

div(card)                           // best
div(card, { className: 'featured' }) // className merges
div(card, { class: 'featured' })     // replaces: only "featured" is left

String(card) y card.className devuelven ambos los nombres de clase.

Objetos de estilo

#

El objeto que pasas a css() usa propiedades CSS comunes en camelCase, un conjunto de atajos y claves anidadas para pseudoestados, selectores y media queries. TypeScript autocompleta todas ellas. Las propiedades fuera del conjunto tipado, como borderTopLeftRadius, fill o paddingInline, van en raw (ver más abajo).

Valores

  • Los números se convierten en px, excepto 0 y las propiedades sin unidad: zIndex, opacity, fontWeight, lineHeight, flex, flexGrow, flexShrink, order, aspectRatio, gridColumn y gridRow. En raw, zoom, scale, column-count, orphans, widows, tab-size y animation-iteration-count tampoco llevan unidad.
  • Las cadenas se usan tal como están escritas. null, undefined y false se omiten.
  • lineHeight (y leading) no lleva unidad, así que leading: 24 significa 24 veces el tamaño de fuente. Escribe "24px" para píxeles.
example.ts
css({ p: 16, m: 0, opacity: 0.5, leading: 1.5, z: 10, w: '50%' })
// padding:16px; margin:0; opacity:0.5; line-height:1.5; z-index:10; width:50%

Atajos

ClaveCSS
p, pt, pr, pb, plpadding, y cada lado
px / pypadding izquierdo y derecho / superior e inferior
m, mt, mr, mb, ml, mx, mylo mismo para margin
w, h, minW, maxW, minH, maxHwidth, height, y sus mínimos y máximos
sizewidth y height
bgbackground
text / font / weightfont-size / font-family / font-weight
leading / trackingline-height / letter-spacing
align / items / justifytext-align / align-items / justify-content
z / rounded / shadow / selectz-index / border-radius / box-shadow / user-select

Estas claves aceptan true:

ClaveCSS
rowdisplay: flex; flex-direction: row
coldisplay: flex; flex-direction: column
centeralign-items: center; justify-content: center (no establece display)
truncateoverflow: hidden; text-overflow: ellipsis; white-space: nowrap

Pseudoestados

hover, focus, focusVisible, focusWithin, active, visited, disabled, enabled, checked, required, invalid, valid, readOnly, first (:first-child), last (:last-child), only (:only-child), odd, even, empty, placeholderShown, placeholder, before, after, selection, marker, firstLine y firstLetter. Se anidan: hover: { focus: {…} } produce :hover:focus.

Selectores y at-rules

Una clave que empieza por & es un selector, y cada & se convierte en la clase. Una clave que empieza por @ es una at-rule: @media, @container o @supports.

menu.ts
const menu = css({
  p: 8,
  '& > li': { py: 4 },
  '& > li:hover': { color: 'red' },
  '&:is(.dark *)': { color: 'white' },
  '@media (min-width: 768px)': { p: 16 },
})
  • La clave debe empezar por &. Una clave como ".dark &" se ignora, así que escribe "&:is(.dark *)".
  • Dos bloques @media anidados se combinan con and. Una query que empieza por un tipo de medio (print, only screen) se coloca primero, porque la gramática solo permite un tipo al principio. Un par que no se puede aplanar — dos tipos de medio, o un not inicial — avisa y usa solo la query interna. Cualquier otra at-rule anidada reemplaza a la externa.

raw

Usa raw para cualquier propiedad, con el nombre escrito exactamente como en CSS. Los números siguen recibiendo px, así que pasa cadenas:

example.ts
css({ raw: { '--gap': '8px', '-webkit-line-clamp': '2' } })

Cualquier otra clave plana se convierte a kebab-case y se escribe tal cual, así que una errata como colr: 'red' se convierte en una declaración que el navegador descarta. Un objeto anidado bajo una clave que no es un pseudoestado, un screen, ni una clave & o @ se ignora. TypeScript señala ambos casos.

fnAPI pública
function cx(...inputs: ClassInput[]): StyleResult type ClassInput = StyleResult | string | false | null | undefined | ClassInput[]

Combina estilos. Cuando dos entradas establecen la misma propiedad, gana la entrada posterior.

example.ts
const base = css({ p: 8, color: 'red' })
const blue = css({ color: 'blue' })

cx(base, blue)                  // padding: 8px; color: blue
cx(base, false, null, 'extra')  // base's classes plus "extra"
div(cx(base, blue), 'Blue text')
  • Las entradas falsy se omiten, los arrays se aplanan y las cadenas se separan y se deduplican.
  • Las clases generadas para el mismo bloque (base, el mismo pseudo, la misma media query) se fusionan en una nueva clase. Los demás nombres de clase pasan sin cambios.
  • Solo cx() conoce el orden de los argumentos. Con div(base, blue) se añaden ambas clases, y gana la regla que se creó la última. Pon los estilos que se sobrescriben entre sí en una sola llamada a cx().
  • Cuando el bloque de un estilo con nombre se fusiona con el bloque de otra entrada, la clase fusionada es name-<hash>, no el nombre a secas. Los bloques que no se fusionan conservan su clase, así que cx(pageTitle, 'extra') sigue teniendo page-title.

Clases dinámicas

Pasa una función que siempre devuelva cx(...):

toggle.ts
const baseButton = css({ px: 12, py: 8, border: '1px solid #ccc' })
const activeButton = css({ bg: '#ff3f00', color: 'white' })

let active = false

const Toggle = () =>
  button(
    () => cx(baseButton, active && activeButton),
    () => (active ? 'Active' : 'Inactive'),
    { onClick: () => { active = !active; update() } },
  )

La función debe devolver un resultado de estilo en su primera ejecución. () => active && activeButton renderiza el texto "false" en lugar de una clase.

createCss()

#
fnAPI pública
function createCss<const T extends ThemeConfig>(theme?: T): CssInstance<T> // CssInstance<T> = { css, cx, variants, keyframes, globalStyle, theme }

Devuelve sus propios css, cx, variants, keyframes y globalStyle, vinculados a un tema. Un tema da nombres a valores: con el tema de abajo, borderColor: 'border' escribe #e5e7eb, y TypeScript autocompleta los nombres. Cada entrada de screens se convierte en una clave de estilo para una media query, como md: {…}.

theme.ts
export const { css, cx, variants } = createCss({
  colors: { primary: '#ff3f00', border: '#e5e7eb' },
  radii: { card: '12px' },
  screens: { md: '(min-width: 768px)' },
})

const panel = css({
  p: 12,
  rounded: 'card',
  border: '1px solid',
  borderColor: 'border',
  md: { p: 24 },
})
Clave del temaUsada por
colorsbg, backgroundColor, color, borderColor y cada lado, outlineColor, caretColor, textDecorationColor
fontsfont, fontFamily
shadowsshadow, boxShadow
radiirounded, borderRadius
screensnuevas claves de estilo, como md: {…}
  • Un nombre del tema solo se resuelve cuando es el valor completo. border: "1px solid primary" no se resuelve.
  • Un valor de screen sin @ se convierte en @media <value>. Un valor que empieza por @ se usa tal como está escrito, por ejemplo "@container (min-width: 400px)".
  • Los screens solo existen en las instancias creadas con createCss(). El css() global no tiene ninguno, pero acepta claves @media.
  • Todas las instancias y los helpers globales comparten una sola hoja de estilos. Las reglas media de los screens se emiten en el orden en que se declaran los screens, no en el orden en que los usas, así que lista los screens min-width de menor a mayor. Usa una sola instancia de createCss() por aplicación, para que ese orden sea el de tu tema.
  • theme en el resultado es el tema que pasaste.

variants()

#
fnAPI pública
function variants(config: { base?: Style variants?: { [group: string]: { [value: string]: Style } } defaultVariants?: { [group: string]: value } compoundVariants?: ({ [group: string]: value } & { css: Style })[] }): (props?: { [group: string]: value }) => StyleResult

Construye una función que devuelve un estilo para un conjunto de opciones, como la intención y el tamaño de un botón.

button.ts
const buttonClass = variants({
  base: { px: 12, py: 8, rounded: 6 },
  variants: {
    intent: { primary: { bg: '#2563eb' }, danger: { bg: '#dc2626' } },
    size: { sm: { text: 12 }, lg: { text: 18 } },
    block: { true: { w: '100%' } },
  },
  defaultVariants: { intent: 'primary', size: 'sm' },
  compoundVariants: [{ intent: 'danger', size: 'lg', css: { weight: 700 } }],
})

button(buttonClass(), 'Save')                                                // primary, sm
button(buttonClass({ intent: 'danger', size: 'lg', block: true }), 'Delete') // bold, full width
  • Los estilos se fusionan en este orden: base, el valor elegido de cada grupo y luego cada compound que coincida. Los estilos posteriores ganan por propiedad.
  • Una opción ausente, undefined o null usa el valor por defecto. Un grupo sin valor por defecto se omite.
  • Un grupo con solo las claves true y false acepta un booleano.
  • Un compound se aplica cuando coincide cada grupo que lista, incluidos los valores por defecto.
  • Cada selección se construye una vez y se cachea.

keyframes() y globalStyle()

#
fnAPI pública
function keyframes(frames: KeyframeFrames): string function globalStyle(selector: string, style: FlatStyle): void

keyframes() registra una animación y devuelve su nombre generado. Las claves de los frames son from, to y porcentajes, como "50%" o "0%, 100%". Los frames solo aceptan propiedades planas. Frames idénticos devuelven el mismo nombre.

fade.ts
const fadeIn = keyframes({
  from: { opacity: 0 },
  to: { opacity: 1 },
})

const panel = css({ animation: `${fadeIn} 200ms ease-out` })

globalStyle() añade una regla para cualquier selector:

reset.ts
globalStyle('*, *::before, *::after', { boxSizing: 'border-box' })
globalStyle('body', { m: 0, font: 'system-ui, sans-serif' })
  • El selector se usa exactamente como está escrito.
  • Solo funcionan las propiedades planas. Las claves pseudo, los screens y las claves @ se ignoran, así que globalStyle() no puede escribir reglas media.
  • La misma regla se añade una sola vez. Un estilo distinto para el mismo selector añade una segunda regla, y gana la posterior.

getCssText() y resetStyles()

#
fnAPI pública
function getCssText(): string // from 'nuclo/ssr' or 'nuclo' function resetStyles(): void // from 'nuclo'

getCssText() devuelve todas las reglas que css(), cx(), variants(), keyframes() y globalStyle() han creado en este proceso: primero las reglas base, luego los bloques @media, @container y @supports. Úsalo para el renderizado en el servidor.

  • Llámalo después de renderToString(). Las reglas creadas durante el renderizado quedan incluidas.
  • Cubre todo el proceso, no una página o una petición, y solo crece. Eso es seguro, porque los nombres de clase son hashes del contenido.
  • Ponlo en <style id="nuclo-styles">. El cliente reutiliza ese elemento y omite las reglas que ya contiene. Con otro id, o sin ninguno, el cliente añade una segunda hoja de estilos con todas las reglas de nuevo.
  • El texto no se escapa, así que nunca pongas entrada no confiable en valores de estilo.

resetStyles() es un helper para tests. Elimina todas las reglas y el elemento #nuclo-styles. Los estilos que creaste antes conservan sus nombres de clase pero pierden su CSS. No lo llames en una aplicación ni en un servidor.

setup.test.ts
import { resetStyles } from 'nuclo'

beforeEach(() => resetStyles())

nuclo-router

#
Paquete independiente: nuclo-router no forma parte del paquete principal de Nuclo. Instálalo junto a nuclo. Es una versión preliminar, así que su API aún puede cambiar.

Enrutamiento para Nuclo: renderizado en el servidor, hidratación, rutas con code splitting y precarga en reposo, construido sobre list() y update(). Unos 4.9 KB comprimido con gzip, sin dependencias más allá del propio Nuclo y sin efectos secundarios, así que el tree-shaking lo elimina cuando no se usa.

terminal
$ npm install nuclo nuclo-router

O usa pnpm add, yarn add o bun add con los mismos dos nombres. Necesita nuclo 0.3 o posterior como peer dependency.

El router decide qué cargar. La aplicación dice dónde con router.outlet(): un list() sobre la pila de capas, una fila por capa, así que una página normal es una sola fila. El servidor y el cliente construyen el mismo árbol, así que hydrate() reclama los nodos del servidor en lugar de reemplazarlos. Todo lo demás es history, un listener click delegado y requestIdleCallback.

Un solo import

main.ts
export const router = createRouter(routes)   // define once, import anywhere
await router.start()                          // resolve the current URL
render(App, document.getElementById('app')!) // App places router.outlet() and reads router.path, router.go(), …

start() resuelve los módulos de la ruta coincidente antes de devolver, así que el primer árbol está completo tanto en el servidor como en el cliente. Después, el router lee directamente de la ruta que inició: router.path, router.pending, router.go().

Una aplicación completa

routes.ts
// isomorphic: imported by the browser and the server
import { createRouter } from 'nuclo-router'

export const router = createRouter({
  '/':           () => import('./pages/Home.ts'),
  '/docs':       () => import('./pages/Docs.ts'),
  '/blog/:slug': () => import('./pages/Post.ts'),
  '*':           () => import('./pages/NotFound.ts'),
})

export const App = () =>
  div(
    header(a({ href: router.href('/') }, 'Home'), a({ href: router.href('/docs') }, 'Docs')),
    main(
      when(() => router.pending, Spinner()),
      when(() => router.error !== null, ErrorView()),
      router.outlet(),   // where the pages land
    ),
    footer('© nuclo'),
  )
pages/Post.ts
import type { RouteContext } from 'nuclo-router'
import { router } from '../routes.ts'

// A page is plain content
export default function Post(ctx: RouteContext) {
  // A query-only change keeps the page, so the query is read live.
  return article(h1(ctx.params.slug), p(() => (router.search.has('preview') ? 'draft' : '')))
}
main.ts
import 'nuclo'
import { router, App } from './routes.ts'

await router.start()
hydrate(App, document.getElementById('app')!)   // or render() without SSR
server.ts
import 'nuclo/polyfill'
import 'nuclo'
import { renderToString } from 'nuclo/ssr'
import { router, App } from './routes.ts'

// one Route per request
const route = await router.start(request.url)
const html = route.run(() => renderToString(App))

Esa es toda la integración. Los enlaces funcionan porque un <a href> es simplemente un <a href>. Un listener delegado en document intercepta los enlaces que el router posee y deja todos los demás al navegador.

En el servidor, start() devuelve un Route por petición y no toca el router, así que las peticiones concurrentes no pueden ver la ruta de las otras. route.run() hace que ese Route sea el que el router, y por tanto el outlet, lee durante el renderizado. En el navegador hay un Route por carga de página, y start() lo convierte en el propio del router. No hace falta run().

Una página recibe su RouteContext y sus PageProps, como (ctx, { layer, data, outlet, beforeLeave }), y puede aceptar menos argumentos. Devuelve cualquier cosa que list() acepte. Para rellenar también otra parte del layout, devuelve un into() desde dentro de la página, como div(article(…), into('sidebar', nav(…))). Ese contenido se va con la página. Ver Páginas y regiones.

Patrones y rutas anidadas

#
PatrónCoincide conparams
/, /docs/introexactamente esa ruta{}
/blog/:slug/blog/hello{ slug: 'hello' }
/u/:id/edit/u/7/edit{ id: '7' }
/files/*rest/files/a/b.txt{ rest: 'a/b.txt' }
*todo lo que no coincida con nada más, tu 404{ '*': '…' }

La precedencia es: estáticas, luego dinámicas en orden de declaración, luego catch-all con el prefijo más largo primero. Así, '*' solo coincide con lo que nada más coincide y puede estar en cualquier lugar de la tabla. Las barras finales, las barras duplicadas y la codificación porcentual se normalizan, y los valores de los parámetros llegan decodificados. La coincidencia es una comparación por segmentos por cada patrón. No se construyen ni ejecutan expresiones regulares.

Rutas relativas

Un patrón escrito con ./ es relativo al padre que lo declara, y un href escrito con ./ es relativo a la ruta activa. Juntos forman una sección reutilizable que significa lo mismo bajo cualquier padre. El valor de una tabla es o bien un cargador o bien otra tabla, cuyas claves son relativas a ella:

records/routes.ts
import type { RouteTable } from 'nuclo-router'

// a section, with no idea where it lives
export const recordSection: RouteTable = {
  '/':             () => import('./Record.ts'),   // the parent path itself
  './preview':     () => import('./Preview.ts'),
  './preview/raw': () => import('./Raw.ts'),
}
routes.ts
// declare it wherever it belongs
createRouter({
  '/': () => import('./pages/Home.ts'),
  '/invoices/:id':  recordSection,
  '/customers/:id': recordSection,
  '*': () => import('./pages/NotFound.ts'),
})

Esas dos líneas producen los seis patrones sin escribir ninguna ruta dos veces. Ambos padres reciben los mismos objetos cargadores, así que comparten una caché de módulos y un solo import().

  • Dentro de una tabla hija, './x', '/x' y 'x' significan lo mismo, '/' significa la ruta del padre en sí, y '*rest' da a la sección su propio catch-all. El anidamiento llega tan profundo como quieras.
  • Las claves de nivel superior se usan tal como están escritas, así que '*' sigue siendo '*'.
  • Un hijo bajo un padre *catch-all es un error de createRouter(), ya que un catch-all tiene que terminar su patrón.

Páginas padre y outlet()

Un padre y sus hijos son una jerarquía real. El padre se renderiza, y su hijo se renderiza dentro de él, allí donde llame a outlet():

records/Record.ts
import type { PageProps, RouteContext } from 'nuclo-router'

// a parent page takes its outlet from (ctx, { outlet })
export default function Record(ctx: RouteContext, { outlet }: PageProps) {
  return div(Header(ctx), input({ id: 'notes' }), main(outlet()))
}

Navegar entre hijos no reconstruye el padre. Su nodo DOM es el mismo nodo, así que su foco, su posición de scroll, su input a medio escribir y cualquier estado de su closure sobreviven.

  • Una página sin ruta hija no renderiza nada desde su outlet, así que un layout puede llamarlo sin condición. Un padre que tiene un hijo y nunca llama a outlet() se lo traga.
  • No hay rutas índice. '/' es el layout en sí, así que la ruta del padre renderiza el layout con un outlet vacío en lugar de una página índice separada dentro de él. Pon el contenido de llegada en el layout, o dale una ruta hija real.

La misma llamada funciona entonces desde cualquiera de los dos padres, y desde una página que no sabe en cuál está:

example.ts
router.href('./preview')      // '/invoices/42/preview' or '/customers/7/preview'
await router.go('./preview')
await layer.push('./preview') // the usual shape: a child layer

./x, ../x, . y .. se resuelven contra la ruta actual, no contra el "directorio" de la URL. Desde /invoices/42, ./preview es /invoices/42/preview, donde un navegador habría dicho /invoices/preview, y una barra final en la URL no cambia nada. ../ sube un segmento y se detiene en la raíz. La query y el hash se conservan, base se vuelve a añadir, y la ruta absoluta resuelta es lo que entra en el historial. Es la misma desviación de la resolución de URLs que hace React Router para <Link to="./x">.

  • Solo ./ y ../ son relativos a la ruta. Un preview a secas en un href se deja a las reglas normales del navegador.
  • Construye los enlaces relativos con router.href('./preview') en lugar de escribir href="./preview" a mano. href() resuelve en tiempo de renderizado, así que el marcado que se envía es absoluto y sigue siendo correcto para un crawler, para un clic con el botón central y con JavaScript desactivado.

Qué se conserva y qué se reconstruye

NodoAl navegar
un padre cuyo patrón y ruta no cambianse conserva: el mismo nodo, y su load() no se vuelve a ejecutar
la página coincidentese conserva cuando su patrón y ruta no cambian y no tiene load(), así que un cambio de query o hash la mantiene. Una ruta con cargador se refresca en cada visita.
un padre cuya ruta cambióse reconstruye, junto con todo lo que hay debajo
una capa apiladarenderiza solo la página coincidente. Sus padres ya están montados en la fila de debajo.

Una página conservada conserva el ctx con el que se construyó. Ese es el significado de ctx en todas partes: lee router.* para obtener valores en vivo. El estado dirigido por la query, como filtros, paginación y pestañas, lee router.search dentro de un valor dinámico y escribe con router.go('?page=2', { replace: true }). La página, su foco y su input a medio escribir se quedan donde están.

Páginas y regiones

#

router.outlet() coloca la página en un sitio. Una página que además necesita una barra lateral, una barra de herramientas o un encabezado en otro lugar del layout lo rellena con into(). El layout marca el lugar con region(), y la página devuelve un into() junto a su propio contenido. El layout nunca recibe contenido de página como prop, y la navegación nunca lo reconstruye.

app.ts
export const App = () =>
  div(
    aside(
      input({ placeholder: 'Filter' }),   // keeps its text across navigations
      region({ id: 'sidebar', empty: p('Nothing to show.') }),
    ),
    main(router.outlet()),
  )
pages/Post.ts
import type { PageProps, RouteContext } from 'nuclo-router'

export default function Post(_ctx: RouteContext, { data: post }: PageProps<Post>) {
  return div(
    article(h1(post.title), p(post.body)),
    into('sidebar', nav(h2('In this post'), ul(list(() => post.headings, (h) => li(h))))),
  )
}

El contenido de la página es una fila del list() del outlet, y un into() vive tanto como la fila que lo contiene. Así que el contenido de la barra lateral llega con la página y se va con ella: navegar a una página sin un into('sidebar', …) muestra de nuevo el contenido empty de la región. La página puede usar su ctx y su data para ambas partes, ya que ambas las construye la misma función.

Qué ocurre al navegar

  • Una página conservada conserva sus regiones. Un cambio de query o hash que conserva la página tampoco toca el contenido de la región. Lee router.search dentro de un valor dinámico si la barra lateral depende de ello.
  • Una página reconstruida las vuelve a rellenar. Cuando la ruta cambia, la fila antigua se elimina y las vistas into() de la nueva página toman el relevo. No queda nada atrás.
  • Una capa apilada añade una vista. La capa es una fila posterior del outlet, así que su into() es una vista más reciente de la misma región. Con el type: 'latest' por defecto cubre la vista de la página hasta que la capa se cierra, y entonces la vista de la página se muestra de nuevo. Con type: 'stack' se muestran ambas.
  • Una página padre también puede rellenar regiones. Sus vistas permanecen mientras sus hijos cambian, porque el padre no se reconstruye.
  • El renderizado en el servidor funciona tal cual. El servidor construye la página dentro de route.run(), así que sus vistas into() están en el HTML, y hydrate() las reclama.

Varias regiones desde una página

Pasa un objeto para rellenar más de una región a la vez. El contenido principal de la página sigue pasando por el outlet, así que devuélvelo junto al into():

pages/Report.ts
export default function Report(ctx: RouteContext) {
  return div(
    table(…),
    into({
      toolbar: [button('Export'), button('Print')],
      sidebar: nav(a({ href: router.href('./summary') }, 'Summary')),
    }),
  )
}

Qué herramienta para cada tarea

  • outlet() es para una ruta hija: contenido que decide la URL, renderizado dentro de su página padre. Ver Rutas anidadas.
  • into() es para que la página coincidente se extienda por el layout. El contenido pertenece a la página y usa sus datos.
  • matches() es para que el layout decida solo a partir de la URL, con su propia tabla pequeña y sin ninguna página involucrada. Ver matches().

Todo esto pertenece a la única ruta coincidente. El router no dirige dos áreas independientes desde una URL, así que una barra lateral con sus propias rutas es una región que una página rellena, no un segundo outlet. Una barra lateral que realmente quiere su propio router, carga y fallback mantiene uno sin iniciar y resuelve con match() — ver Varios routers y carga combinada.

Cargadores de ruta y code splitting

#

Un módulo de página puede exportar una función load. Se ejecuta antes de construir la página, en cada navegación a esa ruta, y lo que devuelve se entrega a la página como data:

pages/Post.ts
import type { DataLoader, PageProps, RouteContext } from 'nuclo-router'

export const load: DataLoader<Post> = (ctx) => fetchPost(ctx.params.slug)

export default function Post(_ctx: RouteContext, { data: post }: PageProps<Post>) {
  return article(h1(post.title), p(post.body))
}

La tabla de rutas no cambia. El cargador viaja con la página. Recibe el RouteContext coincidente, así que params, search y path están disponibles. Puede ser sincrónico o asíncrono, y también se ejecuta en el servidor, así que una página renderizada en el servidor llega con sus datos ya en el HTML en lugar de hacer fetch después de la hidratación.

El módulo se cachea. Los datos no. El chunk de una ruta se descarga una vez y se reutiliza, y su cargador se ejecuta de nuevo en cada navegación. Por eso /blog/a y /blog/b no pueden confundirse, y por eso un cargador que falla simplemente se puede reintentar.

SituaciónComportamiento
mientras se ejecutarouter.pending es true y la página en la que estabas sigue visible
lanza o rechazaacaba en router.error. go() sigue sin rechazar nunca, y la navegación se puede reintentar.
durante start()el rechazo sale de start(). Aún no hay ningún Route donde ponerlo.
una capa apiladaejecuta su propio cargador, con sus propios datos
el precargador en repososolo calienta módulos. No tiene contexto que pasar, y estaría obteniendo datos para una ruta que quizá nunca abras.
volver a navegar a la misma URLel cargador se ejecuta de nuevo y la fila se reconstruye. Una ruta sin cargador conserva su fila.

Para revalidar, navega a donde ya estás sin añadir una entrada al historial:

example.ts
await router.go(router.url, { replace: true })   // runs load() again

Iniciar los datos junto con el chunk

Un load exportado por el módulo de la página solo puede ejecutarse una vez que ese módulo ha llegado, así que una navegación en frío descarga el chunk y luego los datos. Para iniciar ambos a la vez, declara el cargador en la tabla con withLoad():

routes.ts
import { createRouter, withLoad } from 'nuclo-router'
import { loadPost } from './data.ts'   // small, in the main bundle

createRouter({
  '/blog/:slug': withLoad(() => import('./pages/Post.ts'), loadPost),
})

Reemplaza el load propio del módulo y se ejecuta en cada visita igual que él. Un rechazo mientras el chunk todavía se está cargando acaba en router.error como cualquier otro.

No hay serialización de los datos del servidor en el HTML, por diseño. El load del cliente se ejecuta de nuevo en la hidratación. Si eso importa, haz que el servidor emita route.data, por ejemplo como window.__DATA__, y que el cargador lo tome primero. route.data son solo los datos de la página coincidente. El resultado del load() de un padre no está ahí.

Code splitting y precarga

El valor de una ruta es siempre una función que devuelve el componente de página, así que el componente se alcanza de forma perezosa:

routes.ts
'/a': () => import('./A.ts'),                      // default export (+ optional load)
'/b': () => import('./B.ts').then(m => m.BPage),   // named export
'/c': () => CPage,                                 // eager, not split
'/d': () => ({ default: DPage, load: loadD }),     // eager, module-shaped

Solo las formas con forma de módulo pueden llevar un export load. Elegir un componente con nombre con .then(m => m.BPage) entrega al router solo la función.

Cada módulo se importa una vez y se cachea. Las navegaciones concurrentes a la misma ruta comparten un solo import(). Una vez que el usuario ha dejado la página en reposo durante idleTimeout (un segundo por defecto; sin toques, teclas, rueda ni scroll), las rutas restantes se cargan en segundo plano, una por cada franja de reposo (requestIdleCallback). Cualquier entrada, o una navegación que el usuario está esperando, lo pausa hasta que la página vuelve a estar en reposo.

Una ruta precargada o eager nunca activa router.pending y aterriza antes del siguiente paint: sin frame de spinner, sin salto de layout. router.pending solo es true para un módulo o cargador que hay que esperar, y la página saliente permanece en pantalla mientras tanto. Un chunk que no se carga aparece como router.error, nunca como un rechazo no gestionado, y simplemente se puede reintentar.

API del router

#
fnAPI pública
// import { ... } from 'nuclo-router' function createRouter(table: RouteTable, options?: RouterOptions): Router function withLoad(loader: RouteLoader, load: DataLoader): RouteLoader interface RouterOptions { base?: string; preload?: boolean; idleTimeout?: number; onNavigate?: (ctx: RouteContext, kind: NavigationKind) => void }

createRouter(table, options?)

table asocia un patrón a un cargador, o a una tabla anidada. Opciones:

OpciónPor defecto
base'/'Prefijo de URL bajo el que se sirve la aplicación, como '/docs'. Solo coincide en un límite de segmento: /docs/intro está dentro de '/docs', /docsearch no. Las URLs fuera de él no coinciden con nada, así que start() rechaza (tu 404 del lado del servidor) y los enlaces hacia ellas se dejan al navegador.
preloadtrueCarga los módulos de las rutas restantes en segundo plano una vez que la página está en reposo. false lo desactiva.
idleTimeout1000Milisegundos sin toques, teclas, rueda ni scroll antes de que empiece la precarga, o se reanude después de que el usuario estuviera activo.
onNavigate(ctx, kind) => void, llamado después de cada navegación resuelta en el navegador, incluida la inicial, una vez que el DOM se ha puesto al día. kind es 'start', 'push', 'replace', 'pop' (Atrás, Adelante, close()) o 'layer'. El hook para document.title, meta tags, foco, scroll y analítica. Nunca se llama durante el SSR.

Devuelve el router: start(), match(), outlet(), matches(), y cada miembro de Route excepto run(), leídos directamente del Route activo. Ese es el que devolvió el último start() en el navegador, o aquel cuyo run() se está ejecutando en el servidor. Leer un miembro sin ninguno de los dos lanza un error. La tabla de rutas, el matcher compilado y la caché de módulos viven en el router, así que se comparten entre peticiones en un servidor.

router.start(url?)

Resuelve url (por defecto: location.href en el navegador) a sus módulos de página y devuelve un Promise<Route>. Rechaza cuando nada coincide y la tabla no tiene ruta '*'. En un servidor, ese es tu 404. En el navegador también convierte el Route en el activo del router, adjunta los listeners popstate y click delegado y programa la precarga. Un segundo start() retira el Route anterior por ti. En el servidor no toca nada compartido, así que renderiza dentro de route.run().

router.match(url?)

Resuelve una URL contra la tabla sin cargar nada: sin import, sin historial, sin listeners, sin Route. Devuelve un RouteContext, o null cuando la URL está fuera de base, no coincide con ningún patrón o, en el navegador, está en otro origen. Solo URLs absolutas, ya que la resolución de ./ necesita una ruta activa.

example.ts
// 404 before importing a single chunk
if (!router.match(request.url)) return new Response(null, { status: 404 })

// "is this link ours?" for a custom link component or a prefetch rule
const owned = router.match(href) !== null

router.outlet()

Las páginas de la ruta activa como un list(). Colócalo una vez, donde vayan las páginas. Una capa apilada es una fila posterior de él. Está vacío antes de que start() se resuelva y después de stop(). En el servidor renderiza el Route cuyo run() se está ejecutando.

router.matches(table)

Renderiza la vista de una segunda tabla con la que coincide la ruta activa, como un list() que colocas donde quieras. Ver matches().

Route

Todos los miembros salvo run() están también en el router, que es donde una aplicación los lee.

Miembro
path, pattern, params, search, hash, urlla coincidencia activa. search es un URLSearchParams. search, hash y url son en vivo.
pendinguna navegación está esperando un módulo o un cargador
errorla última carga fallida, borrada por la siguiente navegación
datalo que devolvió el load() de la página coincidente, para que un servidor lo serialice. El de un padre no está ahí.
depthcuántas capas hay en la pila (1 para una página normal)
go(href, { replace })navega, cargando el módulo si hace falta. Nunca rechaza: un módulo que falla, rechazando o lanzando, acaba en error.
push(href)abre una ruta como una nueva capa encima, resolviéndose con aquello con lo que se cierra. Acepta ./. Rechaza si no hay coincidencia o el módulo falla.
href(path)antepone base a una ruta, para a({ href }). Resuelve ./ y ../ contra la ruta activa.
stop()desconecta todos los listeners, descarta las capas abiertas, vacía el outlet. Idempotente. Un Route detenido sigue respondiendo con su última coincidencia, y go() no hace nada.
run(fn)llama a fn con este Route como el activo del router y devuelve su resultado: route.run(() => renderToString(App)) en el servidor. Solo sincrónico. El Route deja de estar activo cuando fn devuelve.

RouteContext

El primer argumento de una página: path, pattern, params, search, hash, url. Es el contexto con el que se construyó la página. Lee router.* para obtener valores en vivo. Solo difieren cuando la query o el hash cambiaron sin una reconstrucción.

params es { [name]: string } por defecto. Una página puede tiparlo a partir de su propio patrón con Params, que convierte '/blog/:slug' en { slug: string } y '/files/*rest' en { rest: string }:

pages/Post.ts
import type { Params, RouteContext } from 'nuclo-router'

export default function Post(ctx: RouteContext<Params<'/blog/:slug'>>) {
  return article(h1(ctx.params.slug))   // slug: string, nothing else exists
}

PageProps

El segundo argumento de una página. Desestructura lo que la página necesite.

Prop
layerdónde está la página en la pila. Ver Layer más abajo.
datalo que devolvió el load() de la ruta, o undefined si no hay ninguno. Tipado con PageProps<TData>.
outletdónde una página padre renderiza su ruta hija. Ver Rutas anidadas.
beforeLeaveregistra una guarda que se consulta antes de descartar esta página. Ver Guardas de salida.

Layer

Dónde está una página en la pila, y su forma de salir.

Miembro
depth0 para la página base, 1 en adelante para las capas apiladas
push(href)abre otra capa desde dentro de una página, igual que Route.push()
close(result?)cierra esta capa y todo lo que hay encima, resolviendo el push() que la abrió. Sin argumento significa descartada. No hace nada en la profundidad 0.

DataLoader y withLoad()

DataLoader es (ctx: RouteContext) => TData | Promise<TData>, el export opcional load de un módulo de página. withLoad(loader, load) crea una ruta cuyo load empieza junto al import de su chunk en lugar de después. Ver Cargadores de ruta.

Tipos

Cada tipo es un export con nombre de nuclo-router, así que una página, un cargador o una guarda se pueden tipar sin importar desde una ruta de archivo.

TipoQué es
Params<'/blog/:slug'>el objeto de parámetros de un patrón. RouteParams es la forma sin tipar, y Match es el resultado del matcher.
PageComponentuna página: (ctx, { layer, data, outlet, beforeLeave }) => …
RouteModuleun módulo de página: el componente en sí, o { default, load? } tal como lo produce import()
RouteLoader() => RouteModule | Promise<RouteModule>, que opcionalmente lleva un load de withLoad()
RouteTablela tabla que acepta createRouter(): patrón a cargador, o a una tabla anidada
RouterOptions{ base?, preload?, idleTimeout?, onNavigate? }
NavigateOptions{ replace? }, el segundo argumento de go()
NavigationKind'start' | 'push' | 'replace' | 'pop' | 'layer', el segundo argumento de onNavigate
Outletel tipo de outlet() de PageProps
LeaveGuard(to) => boolean | void | Promise<…>, lo que acepta beforeLeave()
MatchViewlo que es una vista de matches(): entran parámetros, sale contenido o un import() de una página

Capas y guardas de salida

#

push() abre una ruta encima de la actual en lugar de reemplazarla, y se resuelve con aquello con lo que esa capa se cierre. Como las páginas son un único list() sobre la pila, abrir una capa es un append. La página de debajo no se vuelve a renderizar, así que su DOM, su foco y su formulario a medio rellenar siguen ahí cuando la capa se cierra.

Eso hace que el flujo de "necesito un registro que aún no existe" funcione sin tirar el formulario que el usuario estaba rellenando:

pages/Edit.ts
// the page underneath
const created = await layer.push<Option>('/options/new')
if (created) { options.push(created); selected = created.id; update() }
pages/NewOption.ts
// the pushed page: (ctx, { layer })
layer.close(created)   // resolves the push() above with the new option
layer.close()          // dismissed: resolves with undefined

Las capas se anidan. Un diálogo que necesita su propio diálogo llama a layer.push() y lo espera con await, sin ninguna referencia al Route.

  • La URL es la capa superior. push() añade una entrada al historial, así que una capa es enlazable y Atrás la cierra, resolviendo su push() como descartada, exactamente igual que Cancelar. Atrás y close() son un único camino de código: close() registra su resultado y retrocede en el historial, y la profundidad en history.state es la autoridad. Los navegadores conservan unas 50 entradas por pestaña, así que tras un largo recorrido por capas la entrada de debajo puede haber desaparecido: entonces close() cierra las capas en el sitio y reescribe la entrada actual con la página que queda visible.
  • Una navegación normal vacía la pila. Un clic en un enlace o un go() no es una capa. Reemplaza todo y descarta todas las capas abiertas, así que ningún llamador se queda esperando.
  • Una carga en frío es una página independiente. La URL nombra la capa superior pero no la pila de debajo, así que abrir directamente la URL de una capa la renderiza en la profundidad 1 con layer.depth === 0 y un close() inerte. Una ruta apilada tiene que tener sentido por sí sola. Ramifica según layer.depth para ofrecer un enlace de salida en lugar de un botón Cancelar muerto.
  • La promesa no puede sobrevivir a una recarga, porque vive en un closure. Trata el resultado como un atajo dentro de un flujo que también funciona por el camino largo.
  • Nadie se queda esperando. stop() descarta todas las capas abiertas, y una capa abandonada por una navegación se resuelve con undefined.

Guardas de salida

Una página que contiene algo que vale la pena conservar, como un formulario a medio rellenar, registra una guarda. Se le pregunta antes de descartar la página, y devolver false (o una promesa de ello) conserva la página:

pages/Edit.ts
import type { PageProps, RouteContext } from 'nuclo-router'

export default function Edit(_ctx: RouteContext, { beforeLeave }: PageProps) {
  let dirty = false
  beforeLeave(() => !dirty || confirm('Discard your changes?'))
  return form(input({ onInput: () => (dirty = true) }), …)
}
  • Solo cuando la página se va. Una navegación hija bajo un layout, o un cambio de query o hash que conserva la página, no pregunta. Una guarda vive y muere con su página, así que no hay nada que desregistrar.
  • Todas las salidas. Un enlace, go(), Atrás y Adelante, el close() de una capa, y go() hacia una URL que el router no posee (salir de la aplicación, donde to es null). Un Atrás o Adelante rechazado se deshace, así que la barra de direcciones se queda en la página. Cerrar la pestaña o recargar es trabajo de beforeunload.
  • to es el RouteContext hacia el que se dirige la navegación. Las guardas se ejecutan de la más interna hacia fuera y se detienen en el primer rechazo. Una que lanza un error conserva la página. close(result) también pasa por la guarda, así que limpia tu flag antes de cerrar con un resultado.
  • El router solo puede deshacer entradas del historial que él mismo escribió. Un Atrás o Adelante hacia una que no escribió, como un enlace nativo #fragment o el pushState de otro script, no se consulta.

matches() y sub-routers

#

router.outlet() renderiza la página. router.matches(table) renderiza otra cosa que depende de dónde estás, como una barra lateral, migas de pan o una barra de herramientas, a partir de su propia tabla pequeña, sin cargar una ruta ni añadir una página:

app.ts
div(
  aside(
    router.matches({
      '/':           () => nav('Home links'),
      '/blog/new':   () => nav('Drafting tips'),
      '/blog/:slug': ({ slug }) => nav(`More like ${slug}`),
      '*':           () => import('./Sidebar.ts'),   // code-split, rendered once loaded
    }),
  ),
  main(router.outlet()),
)
  • La misma coincidencia que la tabla de rutas. Los patrones, la precedencia, la coincidencia sobre la ruta sin procesar y la eliminación de base son los propios del router, así que una vista y una página siempre están de acuerdo sobre la URL. Lee la ruta de la capa superior, como router.path.
  • Una vista es (params, ctx) => content, donde params está tipado a partir de su patrón y ctx es el RouteContext con el pattern y los params de la vista. En su lugar puede devolver un import() de un módulo de página, que se llama con ctx una vez que llega.
  • Si nada coincide, nada se renderiza. Añade una vista '*' como fallback. Está vacío antes de start() y después de stop(), como el outlet.
  • Solo se vuelve a renderizar cuando la ruta cambia, así que una vista conserva su DOM a través de cualquier cosa que conserve la página.
  • Una vista perezosa no renderiza nada hasta que llega su módulo. Un import() fallido se registra con console.error, no establece router.error, y se reintenta en la siguiente navegación. Un update() normal no reintenta.
  • En el servidor una vista perezosa no envía nada, ya que su módulo llega en el cliente. Usa una vista sincrónica para todo lo que deba contener el primer HTML.

Sub-routers

Las rutas se anidan, así que un sub-router suele ser simplemente una tabla anidada. Un solo router: la página '/' propia de la sección es su chrome, y sus hijos se renderizan en el outlet() de esa página, así que el chrome sobrevive a la navegación dentro de la sección:

routes.ts
createRouter({ '/': …, '/docs': docsRoutes, '*': … })

O dale a la funcionalidad un router propio, creado pero nunca iniciado. El padre posee una única entrada catch-all y el hijo resuelve el resto contra sus propios patrones, parámetros y fallback:

routes.ts
// parent table
'/docs/*rest': () => import('./docs/Shell.ts'),
docs/Shell.ts
const docs = createRouter(docsRoutes, { base: '/docs' })
const hit = docs.match()                       // { pattern: '/:topic', … }
const page = await docsRoutes[hit.pattern]()   // the app's own lookup

No iniciarlo nunca es la clave. start() es lo que adjunta los listeners, y dos routers iniciados entran en conflicto. El precio es que el hijo posee la resolución, así que los cargadores de ruta, que ejecuta un router iniciado, te toca llamarlos a ti. Esta forma también reconstruye el shell cuando cambia el resto de la ruta, porque el router ve una sola ruta y el shell resuelve el resto por sí mismo. Una tabla anidada es lo que te da un padre que el router mantiene montado, así que prefiérela a menos que la funcionalidad realmente necesite su propio router, base y fallback.

Varios routers y carga combinada

#

Una aplicación puede tener tantos routers como áreas. La regla que lo mantiene simple: inicia exactamente uno por documento, y deja todos los demás routers sin iniciar, resolviendo URLs con match(). Solo el router iniciado posee la URL, el historial y el listener de clics. El resto son tablas a las que haces preguntas — y cada área puede cargar a su propio ritmo, con su propio spinner.

Carga combinada: una cadena de cargadores

Cada página de una cadena coincidente puede exportar load(). En una navegación en frío a una ruta hija, el cargador del layout y el cargador de la página se ejecutan en paralelo, y un solo router.pending cubre toda la cadena: la página anterior permanece en pantalla hasta que el par se resuelve, así que un árbol a medio cargar nunca se pinta. Cada componente recibe su propio data:

routes.ts
'/shop': {
  '/':          () => import('./pages/Shop.ts'),    // layout, has a load()
  './:section': () => import('./pages/Section.ts'), // page, has a load()
},
pages/Shop.ts
import type { DataLoader, PageProps, RouteContext } from 'nuclo-router'

export const load: DataLoader<ShopData> = async () => {
  await wait(500)                              // a slow API
  return { sections: ['toys', 'books', 'music'] }
}

// the layout gets its own data, and its outlet for the child page
export default function Shop(_ctx: RouteContext, { data, outlet }: PageProps<ShopData>) {
  return div(
    h1('Shop'),
    nav(...data.sections.map((s) => a({ href: router.href(`/shop/${s}`) }, s))),
    main(outlet()),
  )
}
pages/Section.ts
export const load: DataLoader<SectionData> = async (ctx) => {
  await wait(900)                              // slower than the layout's
  return { section: ctx.params.section, items: catalog[ctx.params.section] }
}

// the page gets its own data: the two loaders ran beside each other
export default function Section(_ctx: RouteContext, { data }: PageProps<SectionData>) {
  return article(h2(data.section), ul(...data.items.map((i) => li(i))))
}

Ambos cargadores empiezan juntos, no uno tras otro. router.pending es true mientras se espera cualquiera de los dos, y false el resto del tiempo — una ruta precargada o eager nunca lo activa. Moverse entre secciones conserva el layout, así que solo el cargador de la sección se ejecuta de nuevo; los datos del layout, su DOM y un input a medio escribir sobreviven. Volver a navegar a la misma URL vuelve a ejecutar el cargador de la hoja y reconstruye su fila, exactamente igual que con una sola página.

Carga múltiple: un segundo router con su propio spinner

Un área independiente — una barra lateral, una bandeja, una región en vivo — recibe un router propio, creado pero nunca iniciado, y su propio flag de carga. router.pending pertenece al router iniciado y no dice nada sobre esta área, así que las dos pueden cargar en paralelo y cualquiera puede terminar primero:

sidebar.ts
import { createRouter } from 'nuclo-router'

// Created but never started: start() is what attaches the history and click
// listeners, and only the main router is started. match() attaches nothing.
const standIn = () => ({ default: () => div() })   // never rendered

const sidebarRouter = createRouter({
  '/shop':          standIn,
  '/shop/:section': standIn,
  '*':              standIn,
}, { base: '/docs' })              // the same base as the main router

export const sidebar = { loading: false, info: null as SidebarInfo | null }

export async function refreshSidebar(at: string) {
  const hit = sidebarRouter.match(at)          // the second router resolves
  if (!hit || (hit.pattern !== '/shop' && hit.pattern !== '/shop/:section')) {
    sidebar.info = null; update(); return
  }
  sidebar.loading = true                       // its own flag, its own spinner
  sidebar.info = null; update()
  const data = await fetchSidebar(hit.params.section)
  sidebar.loading = false; sidebar.info = data
  update()
}
routes.ts
createRouter(routes, {
  onNavigate: (ctx, kind) => {
    announce(kind)
    refreshSidebar(ctx.url)   // every navigation re-resolves the sidebar
  },
})

Al segundo router se le pregunta, nunca se le ordena: match() lee la URL y devuelve un contexto, y nada sobre la barra lateral entra jamás en la tabla de rutas principal. onNavigate se ejecuta después de cada navegación resuelta, incluida la inicial, así que es el único lugar que mantiene un área independiente en sintonía con la URL.

  • pending pertenece al router iniciado. Un router nunca iniciado no tiene Route, ni pending, ni error. El área lleva sus propios flags, y un segundo spinner puede ir junto al primero. En la demo, la barra lateral carga durante 800 ms mientras los cargadores de la ruta se ejecutan durante 900 ms, así que la página puede terminar antes que la barra lateral o al revés.
  • match() no adjunta nada. Cualquier número de routers sin iniciar puede compartir el documento; cada uno tiene su propia tabla, base y caché de módulos, y ninguno toca el historial. Cuestan una tabla y un matcher.
  • Sus cargadores nunca se ejecutan. El router iniciado ejecuta el load() de las páginas con las que coincide. Un router nunca iniciado solo resuelve, así que si sus vistas necesitan datos, obténlos tú mismo — el refreshSidebar() de la demo. Si quieres cargadores, una tabla anidada o una región que una página rellena mantiene ese trabajo en el router iniciado.
  • Protégete de respuestas obsoletas. Un fetch lento de la barra lateral no debe aterrizar en la sección de la que ya te fuiste. Incrementa un contador de generación por cada refresco y descarta las respuestas que ya no coincidan, igual que el propio router retira las navegaciones superadas.
  • Una vista perezosa de matches() tampoco tiene pending. No renderiza nada hasta que llega su módulo. Tres sistemas de carga, tres relojes independientes: el pending de la ruta, tus propios flags, y una vista perezosa que simplemente aún no está.

Valores computados

#

Nuclo no tiene una API de valores computados. Usa una función normal y llámala desde los valores dinámicos:

example.ts
let items = ['apple', 'banana', 'cherry']
let filter = ''

const visible = () => items.filter((item) => item.includes(filter))

const Fruits = () =>
  div(
    p(() => `Showing ${visible().length} of ${items.length}`),
    ul(list(visible, (item) => li(item))),
  )

filter = 'an'
update()

Asincronía y carga

#

Usa async/await normales, y llama a update() cada vez que el estado cambie. Para un fetch, eso significa una vez antes del await y otra después:

example.ts
let status: 'idle' | 'loading' | 'done' | 'error' = 'idle'
let result: unknown = null

async function loadData() {
  status = 'loading'
  update()
  try {
    result = await fetch('/api/data').then((r) => r.json())
    status = 'done'
  } catch {
    status = 'error'
  }
  update()
}

Buenas prácticas

#
  • Agrupa los cambios. Cambia todo el estado que necesites y luego llama a update() una vez.
  • Mantén los valores dinámicos rápidos y puros. Todos se ejecutan en cada update().
  • Usa scope() para actualizaciones pequeñas y frecuentes, como la escritura o los temporizadores.
  • Renderiza dentro del documento. Los nodos que no están en el documento cuando se ejecuta update() dejan de actualizarse.
  • Usa when() y list() para hijos que cambian, no una función que devuelve nodos.
  • Usa className, no class, y usa () => cx(...) o { className: () => … } para clases dinámicas.
  • Define los estilos una vez, a nivel de módulo.
  • Renderizado en el servidor: renderiza primero y luego pon getCssText() en <style id="nuclo-styles">. Pasa los datos de la petición como argumentos, y conserva los comentarios HTML en la salida.

Índice de la API

#

Cada función pública, y de dónde viene.

nuclo (global y export con nombre)

nuclo (solo export con nombre)

nuclo/ssr

  • renderToString(input), renderManyToString(inputs), renderToStringWithContainer(input, tag?, attrs?) y getCssText().

nuclo/polyfill

nuclo-router (paquete independiente)

Solo globales

  • 175 constructores de etiquetas: div, span, …, var_, y svgSvg, pathSvg, ….
  • Tipos: NodeModFn, NodeModLike, ExpandedElement y más. Los tipos de estilos como StyleResult también se pueden importar desde 'nuclo'.

Claves especiales

Preguntas frecuentes

#

¿Cambiar el estado actualiza la interfaz?

No. El DOM se queda como está hasta que llamas a update().

¿Nuclo usa signals, proxies o un DOM virtual?

No. El estado sigue siendo JavaScript plano. Cuando se renderiza un árbol, Nuclo crea elementos y nodos de texto reales. update() ejecuta todos los valores dinámicos registrados, o los que están dentro de los scopes indicados, y parchea esos mismos nodos donde un valor cambió. No hay grafo de dependencias ni árbol virtual.

¿Por qué mi página muestra "false"?

Los booleanos se renderizan como texto. div(ok && span('Done')) muestra "false". Usa when(() => ok, span('Done')) para hijos, y ok ? 'Done' : '' para texto.

¿Por qué la fila de mi lista no cambia cuando edito el elemento?

Las filas de los elementos que siguen en la lista se conservan, no se renderizan de nuevo. Usa valores dinámicos como () => todo.text para los campos que cambian. Ver list().

¿Por qué una parte de mi interfaz deja de actualizarse?

Normalmente sus nodos no estaban en el documento cuando se ejecutó update(). Nuclo deja de rastrear esos nodos, incluso después de que los adjuntes, así que renderiza en un contenedor que esté en el documento. Otras dos causas: un when() cuya condición lanza un error deja de actualizarse, y una función de texto que lanza un error en su primera ejecución se queda vacía. Ver update().

¿Cuándo debo usar forceUpdate()?

Solo para cambios poco frecuentes en valores estáticos, como cambiar el idioma. Para todo lo demás, usa update(). Ver forceUpdate().

¿Cómo coloco una página en un layout sin pasarla como prop?

Marca el lugar con region({ id: 'main' }) y haz que la página devuelva into('main', …). Ver region() e into().

¿Cómo desmonto una aplicación?

Envuélvela en when() y desactiva la condición. Eliminar el DOM por tu cuenta no ejecuta onDestroy. Ver Ciclo de vida.

¿Nuclo incluye un router?

No en el paquete principal. El enrutamiento se distribuye por separado como nuclo-router, con renderizado en el servidor, hidratación, rutas con code splitting y precarga en reposo. Instálalo con npm install nuclo nuclo-router. Ver nuclo-router.

¿Nuclo tiene efectos?

No. onMount y onDestroy se ejecutan una vez por elemento, ligados a la inserción y la eliminación. Nada se vuelve a ejecutar porque haya cambiado algún estado.