Módulo 17: Activity, View Transitions y más

Objetivos del módulo

  • Ocultar parte de la interfaz con <Activity> sin perder su estado ni dejar efectos en marcha
  • Precargar una pantalla que el usuario aún no ve
  • Saber para qué sirve cacheSignal y por qué hoy solo tiene sentido en Server Components
  • Animar cambios de interfaz con <ViewTransition> y entender qué hace el navegador por debajo
  • Distinguir los cuatro disparadores: enter, exit, update y share
  • Personalizar animaciones con clases CSS, con addTransitionType y con la Web Animations API
  • Combinar View Transitions con listas, Suspense y React Router
  • Sacar un componente del renderizado en servidor con browser()
  • Proteger una aplicación contra XSS con Trusted Types y saber qué cubre React de verdad
  • Conocer los demás cambios de 19.3 y dónde se tratan en el curso

💡 Consejo: Todo lo de este módulo está marcado apartado por apartado, porque cada función llegó en una versión distinta. <Activity> y cacheSignal son de 19.2, y lo demás, de 19.3. Si tu proyecto usa una versión anterior, cada caja te dice qué hacer en su lugar. Las diferencias entre versiones se han medido con 18.3.1, 19.0.9, 19.1.10, 19.2.9 y 19.3.1 en Chromium 156.


1. Prepara el proyecto

Crea un proyecto como en el módulo 01, donde se explica también qué cambia si usas PowerShell:

npm create vite@latest modulo-17 -- --template react-ts
cd modulo-17
npm install
npm run dev

Descarga la API de ejemplo y arráncala en otra terminal con un pequeño retardo, para que los estados de carga se vean:

RETARDO=150 node server.mjs

En PowerShell son dos órdenes: $env:RETARDO=150; node server.mjs. La API queda en http://localhost:3000/api.

Cada ejemplo completo sustituye a src/App.tsx. Borra src/App.css y sustituye src/index.css por estas reglas. La segunda mitad define las animaciones que usarás a partir del apartado 6, y las explicaremos allí:

:root {
  font-family: 'Segoe UI', system-ui, sans-serif;
  color: #263238;
  background: #f5f6fa;
}

body {
  margin: 0;
  padding: 1.25rem;
}

.tarjeta {
  background: #fff;
  border: 1px solid #c5cae9;
  border-radius: 10px;
  padding: 1rem 1.25rem;
  max-width: 680px;
  margin: 0 0 1rem;
}

.tarjeta h2 {
  margin: 0 0 0.5rem;
  font-size: 1.15rem;
  color: #1a237e;
}

.fila {
  display: flex;
  gap: 0.5rem;
  align-items: center;
  flex-wrap: wrap;
  margin-bottom: 0.75rem;
}

button {
  font: inherit;
  padding: 0.35rem 0.8rem;
  border: 1px solid #3f51b5;
  border-radius: 6px;
  background: #fff;
  color: #283593;
  cursor: pointer;
}

button[aria-pressed='true'] {
  background: #3f51b5;
  color: #fff;
}

input,
textarea {
  font: inherit;
  padding: 0.35rem 0.5rem;
  border: 1px solid #9fa8da;
  border-radius: 6px;
  box-sizing: border-box;
}

textarea {
  width: 100%;
  min-height: 5rem;
}

ul.simple {
  margin: 0;
  padding: 0;
  list-style: none;
}

ul.simple li {
  padding: 0.3rem 0;
  border-bottom: 1px solid #eceff1;
}

.registro {
  font: 0.85rem Consolas, monospace;
  background: #eceff1;
  border-radius: 6px;
  padding: 0.5rem 0.75rem;
  margin: 0;
  min-height: 3rem;
  white-space: pre-wrap;
}

.discreto {
  color: #607d8b;
}

.miniatura {
  display: grid;
  place-items: center;
  width: 64px;
  height: 64px;
  border-radius: 10px;
  background: #3f51b5;
  color: #fff;
  font-size: 1.5rem;
  font-weight: 600;
}

.rejilla {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
  gap: 0.75rem;
}

.rejilla button {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.4rem;
  padding: 0.6rem;
}

.detalle {
  display: flex;
  gap: 1rem;
  align-items: flex-start;
}

.detalle .miniatura {
  width: 180px;
  height: 180px;
  font-size: 4rem;
  flex: none;
}

.esqueleto {
  height: 5.5rem;
  border-radius: 8px;
  background: #eceff1;
}

/* Animaciones de View Transitions */
@keyframes desde-derecha {
  from { transform: translateX(60px); opacity: 0; }
}

@keyframes desde-izquierda {
  from { transform: translateX(-60px); opacity: 0; }
}

@keyframes hacia-derecha {
  to { transform: translateX(60px); opacity: 0; }
}

@keyframes hacia-izquierda {
  to { transform: translateX(-60px); opacity: 0; }
}

@keyframes aparecer-abajo {
  from { transform: translateY(16px); opacity: 0; }
}

@keyframes salir-abajo {
  to { transform: translateY(16px); opacity: 0; }
}

::view-transition-new(.entra-abajo) {
  animation: aparecer-abajo 250ms ease-out;
}

::view-transition-old(.sale-abajo) {
  animation: salir-abajo 200ms ease-in;
}

::view-transition-new(.entra-derecha) {
  animation: desde-derecha 280ms ease-out;
}

::view-transition-new(.entra-izquierda) {
  animation: desde-izquierda 280ms ease-out;
}

::view-transition-old(.sale-derecha) {
  animation: hacia-derecha 220ms ease-in;
}

::view-transition-old(.sale-izquierda) {
  animation: hacia-izquierda 220ms ease-in;
}

::view-transition-group(.morfosis) {
  animation-duration: 350ms;
  animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
}

/* Respeta a quien pide menos movimiento */
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

Comprueba que tienes la versión adecuada con npm ls react. Este es el mapa del módulo:

Novedad Versión Para qué sirve
<Activity> 19.2 Ocultar sin perder estado
cacheSignal 19.2 Cancelar trabajo cuando acaba un render en servidor
<ViewTransition> 19.3 Animar cambios de interfaz
addTransitionType 19.3 Elegir la animación según la causa
browser() 19.3 Renderizar un componente solo en el navegador
Trusted Types 19.3 Aceptar valores de confianza en más sitios
Transiciones independientes 19.3 Que una transición lenta no retenga a otra

2. <Activity>: ocultar sin perder 🆕 19.2

🆕 Desde React 19.2. No existe en React 19.1 ni anteriores, donde tampoco hay una versión unstable_. Si usas una versión anterior: oculta con style={{ display: 'none' }}, que conserva estado y DOM pero deja los efectos activos, o eleva el estado al padre si te basta con guardar los datos.

Imagina un panel con pestañas. El usuario filtra empleados, cambia de pestaña, vuelve y el filtro ha desaparecido. La causa es que {activa === 'empleados' && <Empleados />} desmonta el componente, y al desmontarlo React tira su estado.

Hasta ahora tenías dos salidas, y ninguna era buena:

  • Elevar el estado al padre. Funciona, pero el padre acaba guardando cosas que no le incumben, y el DOM se sigue perdiendo: el texto de un <textarea> no controlado, la posición del scroll, un vídeo a medias.
  • Ocultar con CSS. Conservas todo, pero el componente sigue vivo: sus intervalos siguen en marcha y sus suscripciones siguen recibiendo datos, aunque nadie los vea.

<Activity> es la tercera vía. Sus hijos pueden estar en uno de dos modos:

<Activity mode={abierto ? 'visible' : 'hidden'}>
  <Panel />
</Activity>

📘 Concepto: Una Activity oculta se comporta como si sus hijos estuvieran desmontados, con una diferencia: React guarda su estado y su DOM para devolverlos después.

Tabla que compara desmontar, ocultar con display none y Activity según estado, DOM, efectos y renders

Figura 1. Cada forma de ocultar conserva cosas distintas. Activity guarda estado y DOM, y limpia los efectos.

Las props son dos: mode, que vale 'visible' por defecto, y name, opcional, que identifica la frontera en React DevTools.

Un panel con pestañas

Este ejemplo tiene tres pestañas. Empleados guarda un filtro en estado de React, Notas guarda el texto solo en el DOM, y Reloj tiene un intervalo. Una casilla te deja comparar con el renderizado condicional de siempre:

import { Activity, useCallback, useEffect, useState } from 'react'

const API = 'http://localhost:3000/api'

type Pestana = 'empleados' | 'notas' | 'reloj'

interface Empleado {
  empId: number
  nombre: string
  apellidos: string
  oficio: string
}

interface PropsPanel {
  anotar: (mensaje: string) => void
}

function Empleados({ anotar }: PropsPanel) {
  const [lista, setLista] = useState<Empleado[]>([])
  const [filtro, setFiltro] = useState('')

  useEffect(() => {
    anotar('Empleados: efecto creado, pido los datos')
    const control = new AbortController()
    fetch(`${API}/empleados?tamano=100`, { signal: control.signal })
      .then((r) => r.json())
      .then((datos) => setLista(datos.contenido))
      .catch(() => {})
    return () => {
      anotar('Empleados: efecto limpiado')
      control.abort()
    }
  }, [anotar])

  const visibles = lista.filter((e) =>
    `${e.nombre} ${e.apellidos}`.toLowerCase().includes(filtro.toLowerCase()),
  )

  return (
    <>
      <input
        aria-label="Filtrar empleados"
        placeholder="Filtrar por nombre"
        value={filtro}
        onChange={(e) => setFiltro(e.target.value)}
      />
      <ul className="simple">
        {visibles.slice(0, 4).map((e) => (
          <li key={e.empId}>
            {e.nombre} {e.apellidos} <span className="discreto">{e.oficio}</span>
          </li>
        ))}
      </ul>
    </>
  )
}

function Notas() {
  // Sin estado de React: el texto vive solo en el DOM
  return <textarea aria-label="Notas" placeholder="Escribe una nota" />
}

function Reloj({ anotar }: PropsPanel) {
  const [segundos, setSegundos] = useState(0)

  useEffect(() => {
    anotar('Reloj: intervalo creado')
    const id = setInterval(() => setSegundos((s) => s + 1), 1000)
    return () => {
      anotar('Reloj: intervalo limpiado')
      clearInterval(id)
    }
  }, [anotar])

  return <p>Llevas {segundos} s en esta pestaña.</p>
}

const pestanas: Pestana[] = ['empleados', 'notas', 'reloj']

export default function App() {
  const [activa, setActiva] = useState<Pestana>('empleados')
  const [conActivity, setConActivity] = useState(true)
  const [registro, setRegistro] = useState<string[]>([])

  const anotar = useCallback((mensaje: string) => {
    setRegistro((r) => [...r.slice(-5), mensaje])
  }, [])

  function contenido(p: Pestana) {
    if (p === 'empleados') return <Empleados anotar={anotar} />
    if (p === 'notas') return <Notas />
    return <Reloj anotar={anotar} />
  }

  return (
    <section className="tarjeta">
      <h2>Panel de EMPRESA</h2>
      <label className="fila">
        <input
          type="checkbox"
          checked={conActivity}
          onChange={(e) => setConActivity(e.target.checked)}
        />
        Conservar las pestañas con Activity
      </label>
      <div className="fila" role="tablist">
        {pestanas.map((p) => (
          <button key={p} role="tab" aria-pressed={p === activa} onClick={() => setActiva(p)}>
            {p}
          </button>
        ))}
      </div>

      {conActivity
        ? pestanas.map((p) => (
            <Activity key={p} mode={p === activa ? 'visible' : 'hidden'}>
              {contenido(p)}
            </Activity>
          ))
        : contenido(activa)}

      <h2>Registro</h2>
      <pre className="registro">{registro.join('\n')}</pre>
    </section>
  )
}

Escribe gar en el filtro, pasa a notas, escribe algo, pasa a reloj, espera unos segundos y vuelve a empleados:

Panel con la pestaña empleados, el filtro gar conservado y un registro con los efectos creados y limpiados

Figura 2. El filtro sigue ahí tras cambiar de pestaña. El registro muestra que los efectos sí se han limpiado y recreado.

Esto es lo que hemos medido al hacer ese recorrido:

Comprobación Con Activity Condicional
Filtro al volver a empleados gar Vacío
Texto del <textarea> Conservado Se pierde
Segundos del reloj Conserva el valor y se detiene Vuelve a 0

Fíjate en la última línea del registro: Empleados: efecto creado, pido los datos. Al volver a la pestaña se hizo una petición nueva. Es lógico, porque el efecto se destruyó al ocultar y se crea de nuevo al mostrar. Ya sabes del módulo 09 que un efecto debe sincronizar con un sistema externo, y aquí se ve por qué: si está bien escrito, sobrevive a este ciclo sin cambios. Si no quieres repetir la petición, necesitas una caché, como en la solución del ejercicio 2 o con TanStack Query, en el módulo 19.

Qué ocurre exactamente al ocultar

Hemos colocado una sonda con un efecto, un efecto de layout y una ref de función dentro de una Activity. Este es el orden real:

Al ocultar:  layout -   refcb limpieza   efecto -   render hijo
Al mostrar:  render hijo   refcb nodo   layout +   efecto +

De aquí salen cuatro reglas:

  1. El DOM recibe display: none. React lo aplica con un estilo en línea, con !important, sobre los nodos de primer nivel.
  2. Las refs se desconectan. Mientras está oculta, una ref de función recibe su limpieza. No esperes medir nodos de una Activity oculta.
  3. Todos los efectos se limpian, los de useLayoutEffect incluidos.
  4. Los hijos siguen renderizando. Si el padre les pasa props nuevas, las reciben y el DOM oculto se actualiza. En la sonda, la prop pasó de 0 a 1 con la Activity oculta, y el DOM mostró hijo 1. Eso sí, ese trabajo va con menos prioridad que el contenido visible.

💡 Consejo: Strict Mode hace aquí un servicio útil. En desarrollo simula ese desmontaje y montaje de efectos, de modo que un efecto que no sobrevive a una Activity ya falla en Strict Mode. Lo viste en el módulo 05.

Precargar lo que aún no se ve

Una Activity que nace oculta también renderiza a sus hijos, pero sin montar sus efectos. Eso sirve para adelantar trabajo: cuando el usuario entre en la pantalla, ya estará lista.

import { Activity, Suspense, use, useState } from 'react'

const API = 'http://localhost:3000/api'

interface Pedido {
  pedidoId: number
  fechaPedido: string
  estado: string
}

interface Pagina {
  contenido: Pedido[]
}

// La promesa vive fuera del componente: debe ser estable entre renders
let promesaPedidos: Promise<Pagina> | null = null

function cargarPedidos() {
  promesaPedidos ??= fetch(`${API}/pedidos?tamano=5&orden=-fechaPedido`).then((r) => r.json())
  return promesaPedidos
}

function Pedidos() {
  const { contenido } = use(cargarPedidos())
  return (
    <ul className="simple">
      {contenido.map((p) => (
        <li key={p.pedidoId}>
          Pedido {p.pedidoId}, {p.fechaPedido} <span className="discreto">{p.estado}</span>
        </li>
      ))}
    </ul>
  )
}

export default function App() {
  const [verPedidos, setVerPedidos] = useState(false)

  return (
    <section className="tarjeta">
      <h2>Inicio</h2>
      <div className="fila">
        <button aria-pressed={!verPedidos} onClick={() => setVerPedidos(false)}>
          Resumen
        </button>
        <button aria-pressed={verPedidos} onClick={() => setVerPedidos(true)}>
          Pedidos
        </button>
      </div>

      {!verPedidos && <p>Bienvenido al panel. Los pedidos ya se están cargando.</p>}

      <Activity mode={verPedidos ? 'visible' : 'hidden'}>
        <Suspense fallback={<p className="discreto">Cargando pedidos...</p>}>
          <Pedidos />
        </Suspense>
      </Activity>
    </section>
  )
}

Lo que medimos: al cargar la página ya hay una petición a /pedidos, aunque nadie la haya pedido. Al pulsar el botón, los datos aparecen en 14 ms y sin pasar por el mensaje de carga.

Pestaña Pedidos con cinco pedidos mostrados de inmediato

Figura 3. Los pedidos aparecen sin mensaje de carga, porque se habían pedido antes de mostrar la pestaña.

⚠️ Importante: Esto solo funciona con datos que activan Suspense, como una promesa leída con use (lo viste en el módulo 15). Una petición dentro de useEffect no se precarga, porque los efectos no se montan mientras la Activity está oculta.

Efectos del DOM que persisten

Ocultar no apaga lo que hace el propio DOM. Un <audio> o un <video> que suena sigue sonando con display: none. Lo hemos comprobado: al ocultar un <audio> en reproducción, su propiedad paused sigue en false. Con esta limpieza pasa a true:

function Video({ origen }: { origen: string }) {
  const ref = useRef<HTMLVideoElement>(null)

  useLayoutEffect(() => {
    const video = ref.current!
    return () => video.pause()
  }, [])

  return <video ref={ref} src={origen} controls />
}

La documentación recomienda useLayoutEffect y no useEffect, porque esta limpieza está ligada al momento en que la interfaz se oculta, y un efecto normal podría retrasarse.

Una Activity con solo texto

Si los hijos de una Activity oculta solo devuelven texto, no hay ningún elemento al que aplicar display: none, así que React no pinta nada. En nuestra prueba, el contenedor queda vacío. Con mode="visible" el texto se ve con normalidad.

Hidratación selectiva

Las fronteras de Activity también dividen el árbol en unidades que se hidratan por separado, igual que Suspense. Por eso, en una página renderizada en el servidor, los botones de las pestañas pueden responder antes de que se hidrate el contenido oculto. Verás qué es hidratar en el módulo 23.

¿Cuándo usar cada opción?

Situación Elige
Pestañas o paneles a los que el usuario vuelve <Activity>
Pantalla probable que quieres dejar preparada <Activity mode="hidden">
Contenido que no volverá a mostrarse Renderizado condicional
Solo ocultar sin cambiar nada de los efectos CSS
Listas enormes en pestañas Con cuidado: el DOM oculto sigue ocupando memoria

3. cacheSignal: cancelar trabajo sobrante 🆕 19.2

🆕 Desde React 19.2. No existe en React 19.1 ni anteriores. Si usas una versión anterior: crea tu propio AbortController y llama a abort() cuando sepas que el trabajo ya no sirve. No hay forma directa de enterarse de cuándo acaba el render en servidor.

Este apartado adelanta algo del módulo 23, porque cacheSignal solo sirve hoy en Server Components. Si aún no los conoces, quédate con la idea: son componentes que se ejecutan en el servidor, y un mismo render puede lanzar muchas peticiones.

cache memoriza una función durante un render de servidor. cacheSignal devuelve un AbortSignal que se aborta cuando ese render termina, sea porque ha acabado, porque se ha cancelado o porque ha fallado. Así, una petición que ya nadie va a usar no sigue consumiendo recursos.

import { cache, cacheSignal } from 'react'

const API = 'http://localhost:3000/api'

const pedir = cache(async (ruta: string) => {
  const senal = cacheSignal() as AbortSignal | null
  const respuesta = await fetch(`${API}/${ruta}`, { signal: senal, cache: 'no-store' })
  return respuesta.json()
})

export default async function Resumen() {
  const datos = await pedir('estadisticas')
  return <pre>{JSON.stringify(datos)}</pre>
}

Lo hemos probado en un Server Component con Next.js 16.4.3, que incluye su propia copia de React. Esto es lo que ocurre:

Dónde se llama Devuelve
Durante el render de un Server Component Un AbortSignal, sin abortar
Después de terminar el render La señal pasa a abortada, con el motivo This render completed successfully
En un Client Component, durante el render null
En un Client Component, dentro de un efecto null

Tres avisos:

  • Usa el encadenamiento opcional. Como puede devolver null, comprueba con cacheSignal()?.aborted. La documentación avisa de que el valor en el cliente puede cambiar en el futuro, así que no des por hecho que siempre será null.
  • Solo cancela lo que empieza durante el render. Una petición lanzada fuera del componente, al cargar el módulo, no se cancela.
  • El tipo no ayuda. En @types/react 19.3.0, CacheSignal es una interfaz vacía, y por eso el ejemplo necesita as AbortSignal | null para pasar la señal a fetch.

Si una función falla porque el render se canceló, no lo registres como error:

try {
  return await consultar(id)
} catch (error) {
  if (!cacheSignal()?.aborted) registrar(error)
  return null
}

4. <ViewTransition>: animar cambios de interfaz 🆕 19.3

🆕 Desde React 19.3. No existe en React 19.2 ni anteriores. Si usas una versión anterior: llama tú a document.startViewTransition y aplica el cambio dentro con flushSync, como se explica en el apartado 13. Pierdes la integración con transiciones, Suspense y nombres automáticos.

Animar el paso de una pantalla a otra siempre ha sido difícil en React. Cuando un elemento desaparece, sale del DOM y ya no hay nada que animar. Librerías como Motion resuelven eso con mucho código. Los navegadores ofrecen ahora la View Transition API: el navegador toma una foto de la pantalla, deja que cambies el DOM, y anima de la foto a lo nuevo.

<ViewTransition> conecta esa API con React. Tú marcas qué partes se animan, y React decide cuándo llamar a startViewTransition. No debes llamarla tú.

import { ViewTransition, startTransition, useState } from 'react'

function Ficha() {
  return (
    <ViewTransition>
      <article className="tarjeta">
        <h2>Ana García López</h2>
        <p>Directora general, departamento 10.</p>
      </article>
    </ViewTransition>
  )
}

export default function App() {
  const [visible, setVisible] = useState(false)

  return (
    <>
      <div className="fila">
        <button onClick={() => startTransition(() => setVisible((v) => !v))}>
          Con transición
        </button>
        <button onClick={() => setVisible((v) => !v)}>Sin transición</button>
      </div>
      {visible && <Ficha />}
    </>
  )
}

Pulsa los dos botones. Con transición, la ficha aparece suavemente. Sin transición, aparece de golpe. Medimos las llamadas a document.startViewTransition: una con el primer botón, ninguna con el segundo.

Ficha de empleado a mitad de aparecer, medio transparente

Figura 4. La ficha congelada a media animación. La animación por defecto es un fundido.

📘 Concepto: Un setState normal es urgente: debe verse ya. Una animación tarda, y por eso React solo anima lo que no es urgente. Se activa con actualizaciones dentro de startTransition, con Actions, con useDeferredValue y cuando Suspense revela contenido. Repasa startTransition en el módulo 15.

Qué ocurre por debajo

Cinco pasos: render, nombres, foto antigua, commit y animación, con el árbol de pseudoelementos debajo

Figura 5. React prepara los nombres, el navegador captura la pantalla, React cambia el DOM y el navegador anima.

Durante la animación, el navegador superpone a la página un árbol de pseudoelementos. Hay uno de ::view-transition-group por cada elemento con nombre, que se encarga de moverlo y escalarlo. Dentro, ::view-transition-old es la foto del estado anterior, y ::view-transition-new es la imagen en vivo del nuevo.

De aquí sale una consecuencia importante: se anima una imagen, no tus elementos. No se reacomoda cada párrafo por dentro, sino que la foto se mueve, se escala y se funde. Si quieres que una parte se mueva por su cuenta, necesita su propio <ViewTransition>.

Las props

Prop Tipo Para qué
name string Nombre fijo, solo para elementos compartidos
enter, exit, update, share Clase, 'auto', 'none' u objeto Animación de cada disparador
default Igual que las anteriores Valor para los que no indiques
onEnter, onExit, onUpdate, onShare Función Animar con JavaScript

5. Los cuatro disparadores 🆕 19.3

🆕 Desde React 19.3. No existe en React 19.2 ni anteriores. Si usas una versión anterior: distingue tú los casos con document.startViewTransition, poniendo y quitando view-transition-name a mano, como en el apartado 13.

React decide solo qué tipo de animación corresponde, según cómo ha cambiado el árbol:

Cuatro paneles que representan enter, exit, update y share

Figura 6. enter y exit dependen de montar y desmontar. update de un cambio dentro. share de dos límites con el mismo nombre.

  • enter: el <ViewTransition> se monta en esta transición.
  • exit: se desmonta.
  • update: React cambia algo del DOM de dentro, o el límite se mueve o cambia de tamaño porque ha cambiado un hermano.
  • share: un límite con nombre se desmonta y otro con el mismo nombre se monta en la misma transición.

La regla de colocación

⚠️ Importante: enter y exit solo se activan si el <ViewTransition> va antes de cualquier nodo del DOM dentro de lo que se monta. Es el fallo más habitual con esta API.

// ❌ El <div> se monta primero: no hay enter
{visible && (
  <div>
    <ViewTransition>
      <p>Hola</p>
    </ViewTransition>
  </div>
)}

// ✅ El límite es lo primero que se monta
{visible && (
  <ViewTransition>
    <div>
      <p>Hola</p>
    </div>
  </ViewTransition>
)}

Lo hemos comprobado montando ambos: el segundo produce una animación ::view-transition-new para el nuevo elemento, y el primero no produce ninguna.

Varios hijos

Si un <ViewTransition> tiene varios nodos de primer nivel, cada uno recibe su propio nombre. Con dos párrafos medimos _t_5_ y _t_5__1, que se animan por separado.

Apagar lo que no quieres

Por defecto, un límite se anima por cualquier motivo. Con default="none" apagas todo, y luego enciendes lo que necesites:

<ViewTransition default="none" enter="auto" exit="auto">

Y un límite interior puede excluir una parte de la animación de su padre:

<ViewTransition>
  <div className={tema}>
    <ViewTransition update="none">{children}</ViewTransition>
  </div>
</ViewTransition>

6. Personalizar con clases CSS 🆕 19.3

🆕 Desde React 19.3. No existe en React 19.2 ni anteriores. Si usas una versión anterior: asigna view-transition-class o view-transition-name en tu CSS y usa document.startViewTransition. Los selectores ::view-transition-* funcionan igual.

El fundido por defecto se cambia con una View Transition Class. Pasas un nombre de clase a la prop del disparador:

<ViewTransition enter="entra-abajo" exit="sale-abajo">
  <aside>...</aside>
</ViewTransition>

Y en el CSS la seleccionas dentro de los pseudoelementos, con punto. Estas reglas ya están en tu index.css:

@keyframes aparecer-abajo {
  from { transform: translateY(16px); opacity: 0; }
}

::view-transition-new(.entra-abajo) {
  animation: aparecer-abajo 250ms ease-out;
}

En enter no hay nada antiguo, así que anima ::view-transition-new. En exit no hay nada nuevo, y anima ::view-transition-old. En update y share existen los dos, además del grupo que los mueve.

💡 Consejo: Muchos ejemplos antiguos seleccionan por nombre, como ::view-transition-old(mi-nombre). La documentación de React recomienda las clases, porque el nombre debe ser único y una clase se reutiliza.

Activity con View Transition

Las dos novedades encajan. Si un <ViewTransition> está dentro de una Activity que se muestra en una transición, se activa enter, y si se oculta, exit. Medimos las animaciones aparecer-abajo al mostrar y salir-abajo al ocultar. La solución del ejercicio 3 lo aplica.


7. addTransitionType: animar según la causa 🆕 19.3

🆕 Desde React 19.3. No existe en React 19.2 ni anteriores. Si usas una versión anterior: guarda la dirección en una variable de estado antes de navegar, o pásala a document.startViewTransition({ update, types }) si llamas tú a esa API.

Dos botones pueden provocar el mismo cambio de estado, y aun así pedir animaciones distintas. Al pasar de página, “Siguiente” debería deslizar hacia la izquierda, y “Anterior”, al revés. addTransitionType etiqueta la causa dentro de startTransition, y las props aceptan un objeto indexado por esas etiquetas:

import { Suspense, ViewTransition, addTransitionType, startTransition, use, useState } from 'react'

const API = 'http://localhost:3000/api'
const TAMANO = 5

interface Empleado {
  empId: number
  nombre: string
  apellidos: string
  oficio: string
}

interface Pagina {
  contenido: Empleado[]
  paginas: number
}

const cache = new Map<number, Promise<Pagina>>()

function cargarPagina(pagina: number) {
  let promesa = cache.get(pagina)
  if (!promesa) {
    const url = `${API}/empleados?orden=apellidos&tamano=${TAMANO}&pagina=${pagina}`
    promesa = fetch(url).then((r) => r.json() as Promise<Pagina>)
    cache.set(pagina, promesa)
  }
  return promesa
}

function Tabla({ pagina }: { pagina: number }) {
  const { contenido } = use(cargarPagina(pagina))
  return (
    <ul className="simple">
      {contenido.map((e) => (
        <li key={e.empId}>
          {e.apellidos}, {e.nombre} <span className="discreto">{e.oficio}</span>
        </li>
      ))}
    </ul>
  )
}

export default function App() {
  const [pagina, setPagina] = useState(1)
  const total = use(cargarPagina(1)).paginas

  function irA(destino: number, direccion: 'siguiente' | 'anterior') {
    startTransition(() => {
      addTransitionType(direccion)
      setPagina(destino)
    })
  }

  return (
    <section className="tarjeta">
      <h2>Empleados</h2>
      <div className="fila">
        <button disabled={pagina === 1} onClick={() => irA(pagina - 1, 'anterior')}>
          Anterior
        </button>
        <span>
          Página {pagina} de {total}
        </span>
        <button disabled={pagina === total} onClick={() => irA(pagina + 1, 'siguiente')}>
          Siguiente
        </button>
      </div>
      <Suspense fallback={<div className="esqueleto" />}>
        <ViewTransition
          key={pagina}
          default="none"
          enter={{ siguiente: 'entra-derecha', anterior: 'entra-izquierda', default: 'none' }}
          exit={{ siguiente: 'sale-izquierda', anterior: 'sale-derecha', default: 'none' }}
        >
          <div>
            <Tabla pagina={pagina} />
          </div>
        </ViewTransition>
      </Suspense>
    </section>
  )
}

Tres detalles del ejemplo:

  • key={pagina} hace que cada página sea un límite distinto. Por eso sale uno (exit) y entra otro (enter). Sin key, sería un update del mismo límite.
  • 'default' es la entrada que se usa cuando no coincide ningún tipo.
  • La promesa se resuelve dentro de la transición: la página antigua se queda en pantalla hasta que llegan los datos, y la animación ocurre después, con el tipo intacto.

Lista de empleados en plena animación, con la página antigua saliendo y la nueva entrando

Figura 7. Al pulsar Anterior, las dos páginas se cruzan. En el navegador esto se percibe como un deslizamiento.

Medimos que siguiente usa hacia-izquierda y desde-derecha, y anterior, las contrarias. El navegador recibe los tipos en startViewTransition, así que también puedes seleccionarlos solo con CSS:

:root:active-view-transition-type(siguiente) {
  &::view-transition-new(*) {
    animation-duration: 400ms;
  }
}

Reglas de la documentación:

  • Si se combinan varias transiciones, se reúnen todos sus tipos, y puedes añadir más de uno.
  • Si coinciden varios tipos, las clases se unen. Si alguno vale 'none', gana y ese límite no se anima.
  • Los tipos se borran tras cada commit. Un fallback de Suspense los recibe, pero la revelación posterior del contenido no.

8. Elementos compartidos con share 🆕 19.3

🆕 Desde React 19.3. No existe en React 19.2 ni anteriores. Si usas una versión anterior: pon el mismo view-transition-name en los dos elementos, uno antes y otro después del cambio, dentro de document.startViewTransition con flushSync.

La animación más llamativa es la de un elemento que parece viajar de una pantalla a otra: la miniatura de la lista crece hasta ser la imagen del detalle. Son dos elementos distintos, pero con el mismo name.

import { ViewTransition, startTransition, useEffect, useState } from 'react'

const API = 'http://localhost:3000/api'

interface Producto {
  productoId: number
  nombre: string
  descripcion: string
  precio: number
}

function Miniatura({ producto }: { producto: Producto }) {
  return (
    <ViewTransition name={`producto-${producto.productoId}`} share="morfosis">
      <div className="miniatura">{producto.nombre[0]}</div>
    </ViewTransition>
  )
}

export default function App() {
  const [productos, setProductos] = useState<Producto[]>([])
  const [elegido, setElegido] = useState<Producto | null>(null)

  useEffect(() => {
    fetch(`${API}/productos?tamano=6`)
      .then((r) => r.json())
      .then((datos) => setProductos(datos.contenido))
  }, [])

  function abrir(producto: Producto | null) {
    startTransition(() => setElegido(producto))
  }

  if (elegido) {
    return (
      <section className="tarjeta">
        <div className="detalle">
          <Miniatura producto={elegido} />
          <div>
            <h2>{elegido.nombre}</h2>
            <p>{elegido.descripcion}</p>
            <p>{elegido.precio} €</p>
            <button onClick={() => abrir(null)}>Volver al catálogo</button>
          </div>
        </div>
      </section>
    )
  }

  return (
    <section className="tarjeta">
      <h2>Catálogo</h2>
      <div className="rejilla">
        {productos.map((p) => (
          <button key={p.productoId} onClick={() => abrir(p)}>
            <Miniatura producto={p} />
            {p.nombre}
          </button>
        ))}
      </div>
    </section>
  )
}

La miniatura del producto, a medio camino entre la rejilla y el detalle

Figura 8. La miniatura mientras viaja hacia la posición del detalle.

El nombre incluye el identificador porque cada nombre debe ser único en toda la aplicación. Con dos iguales montados a la vez, React escribe en consola There are two <ViewTransition name=%s> components with the same name mounted at the same time, y la transición falla: en nuestra prueba, el navegador la rechazó con InvalidStateError.

Los elementos compartidos no están sujetos a la regla de colocación: aquí, en la rejilla, el límite está dentro de un <button> y funciona.

⚠️ Importante: No se forma la pareja si uno de los dos lados queda fuera de la ventana visible, ni si la transición muestra primero un fallback de Suspense antes de montar el nuevo lado. Además, name es solo para esto: en los demás casos deja que React genere el nombre.

onShare tiene prioridad sobre onEnter y onExit, y solo se dispara un evento por límite en cada transición.


9. Reordenar listas 🆕 19.3

🆕 Desde React 19.3. No existe en React 19.2 ni anteriores. Si usas una versión anterior: asigna un view-transition-name distinto a cada fila y ordena dentro de document.startViewTransition con flushSync.

Para animar un reordenamiento, cada elemento necesita su propio límite, con key en el <ViewTransition>:

import { ViewTransition, startTransition, useEffect, useState } from 'react'

const API = 'http://localhost:3000/api'

interface Empleado {
  empId: number
  nombre: string
  apellidos: string
  salario: number
}

type Orden = 'apellidos' | 'salario'

export default function App() {
  const [empleados, setEmpleados] = useState<Empleado[]>([])
  const [orden, setOrden] = useState<Orden>('apellidos')

  useEffect(() => {
    fetch(`${API}/empleados?tamano=6`)
      .then((r) => r.json())
      .then((datos) => setEmpleados(datos.contenido))
  }, [])

  const ordenados = [...empleados].sort((a, b) =>
    orden === 'salario' ? b.salario - a.salario : a.apellidos.localeCompare(b.apellidos),
  )

  return (
    <section className="tarjeta">
      <h2>Empleados</h2>
      <div className="fila">
        {(['apellidos', 'salario'] as const).map((o) => (
          <button
            key={o}
            aria-pressed={o === orden}
            onClick={() => startTransition(() => setOrden(o))}
          >
            Por {o}
          </button>
        ))}
      </div>
      <ul className="simple">
        {ordenados.map((e) => (
          <ViewTransition key={e.empId}>
            <li>
              {e.apellidos}, {e.nombre} <span className="discreto">{e.salario} €</span>
            </li>
          </ViewTransition>
        ))}
      </ul>
    </section>
  )
}

Filas de empleados cruzándose mientras se reordenan por salario

Figura 9. Cada fila viaja a su nueva posición. Una sola llamada a startViewTransition anima las seis.

Si envolvieras toda la lista en un único límite, no verías moverse las filas: verías un fundido de la lista completa. Cada elemento que deba moverse por su cuenta necesita su frontera.


10. Suspense y View Transitions 🆕 19.3

🆕 Desde React 19.3. No existe en React 19.2 ni anteriores. Si usas una versión anterior: no hay alternativa directa, porque tu código no se entera del momento de la revelación. Puedes animar el contenido con CSS al montarse.

Un <ViewTransition> encima de una frontera de Suspense anima la revelación, de modo que el fallback se funde en el contenido:

import { Suspense, ViewTransition, use, useState } from 'react'

const API = 'http://localhost:3000/api'

interface Estadisticas {
  empleados: number
  productos: number
  pedidos: number
}

let promesa: Promise<Estadisticas> | null = null

function cargar() {
  promesa ??= fetch(`${API}/estadisticas`).then((r) => r.json())
  return promesa
}

function Resumen() {
  const { empleados, productos, pedidos } = use(cargar())
  return (
    <ul className="simple">
      <li>{empleados} empleados</li>
      <li>{productos} productos</li>
      <li>{pedidos} pedidos</li>
    </ul>
  )
}

export default function App() {
  const [mostrar, setMostrar] = useState(false)

  return (
    <section className="tarjeta">
      <h2>Estadísticas</h2>
      <div className="fila">
        <button onClick={() => setMostrar(true)}>Cargar</button>
      </div>
      {mostrar && (
        <ViewTransition update="auto" default="none">
          <Suspense fallback={<div className="esqueleto" />}>
            <Resumen />
          </Suspense>
        </ViewTransition>
      )}
    </section>
  )
}
Tras pulsar Tras recibir los datos
Bloque gris de carga Lista con las estadísticas

Figura 10. El fallback aparece sin animación, porque el botón no usa una transición. Después, la revelación sí se anima.

Aquí setMostrar no va dentro de una transición, y aun así hay animación: una sola llamada a startViewTransition, justo al revelar. Es lo que recomienda el equipo de React: el fallback aparece de inmediato y sin animar, y la llegada del contenido sí se anima. Por eso el ejemplo usa update="auto" default="none".

Hay dos colocaciones, y hacen cosas distintas:

// update: el fallback se funde en el contenido
<ViewTransition>
  <Suspense fallback={<A />}>
    <B />
  </Suspense>
</ViewTransition>

// exit y enter: A sale y B entra, cada uno con su animación
<Suspense fallback={<ViewTransition><A /></ViewTransition>}>
  <ViewTransition><B /></ViewTransition>
</Suspense>

La segunda forma es la del ejercicio 5. Según la documentación, <ViewTransition> también espera hasta 500 ms a que carguen las fuentes nuevas y las imágenes visibles, para que no aparezcan de golpe cuando ya ha empezado la animación.


11. Animar con JavaScript 🆕 19.3

🆕 Desde React 19.3. No existe en React 19.2 ni anteriores. Si usas una versión anterior: espera a la promesa ready de document.startViewTransition y anima con document.documentElement.animate y la opción pseudoElement.

Cuando el CSS no basta, los eventos te dan los pseudoelementos para usar la Web Animations API. Reciben la instancia y los tipos, y deben devolver una limpieza:

import { ViewTransition, addTransitionType, startTransition, useState } from 'react'
import type { ViewTransitionInstance } from 'react'

function animarEntrada(instancia: ViewTransitionInstance, tipos: string[]) {
  const duracion = tipos.includes('rapido') ? 150 : 600
  const animacion = instancia.new.animate(
    [
      { transform: 'scale(0.8)', opacity: 0 },
      { transform: 'scale(1)', opacity: 1 },
    ],
    { duration: duracion, easing: 'ease-out' },
  )
  return () => animacion.cancel()
}

export default function App() {
  const [visible, setVisible] = useState(false)

  function alternar(rapido: boolean) {
    startTransition(() => {
      if (rapido) addTransitionType('rapido')
      setVisible((v) => !v)
    })
  }

  return (
    <>
      <div className="fila">
        <button onClick={() => alternar(false)}>Normal</button>
        <button onClick={() => alternar(true)}>Rápido</button>
      </div>
      {visible && (
        <ViewTransition default="none" enter="auto" onEnter={animarEntrada}>
          <p className="tarjeta">Nuevo aviso: hay 3 pedidos pendientes.</p>
        </ViewTransition>
      )}
    </>
  )
}

Medimos 150 ms con Rápido y 600 ms sin él, así que los tipos llegan al evento.

Dos detalles que no son evidentes:

  • default="none" apaga también el evento: por eso hay que indicar enter="auto".
  • Con 'auto' sigue activo el fundido del navegador, que convive con tu animación. Si quieres solo la tuya, usa una clase con animation: none en el pseudoelemento.

En TypeScript, @types/react declara solo name en ViewTransitionInstance. Las propiedades old, new, group e imagePair están en @types/react-dom. Con la plantilla ya se ven, pero si no aparecen, añade import type {} from 'react-dom', igual que con Fragment Refs en el módulo 10.


12. View Transitions con React Router 🆕 19.3

🆕 Desde React 19.3. No existe en React 19.2 ni anteriores. Si usas una versión anterior: usa la prop viewTransition de <Link>, que llama a document.startViewTransition por su cuenta, aunque React Router 8 exige React 19.2.7 o superior.

Si tu enrutador hace las navegaciones dentro de startTransition, <ViewTransition> funciona sin más. React Router 8 lo hace por defecto. Aquí se adelanta lo mínimo, y el módulo 18 lo explica todo. Instala react-router con npm install react-router, que exige React 19.2.7 o superior.

import { Suspense, ViewTransition } from 'react'
import {
  Link,
  Outlet,
  RouterProvider,
  createBrowserRouter,
  useLoaderData,
  useLocation,
} from 'react-router'

const API = 'http://localhost:3000/api'

interface Departamento {
  deptId: number
  nombre: string
  localidad: string
}

function Marco() {
  const { pathname } = useLocation()
  return (
    <section className="tarjeta">
      <nav className="fila">
        <Link to="/">Inicio</Link>
        <Link to="/departamentos">Departamentos</Link>
      </nav>
      <Suspense fallback={<p>Cargando...</p>}>
        <ViewTransition key={pathname} default="none" enter="entra-derecha" exit="sale-izquierda">
          <div>
            <Outlet />
          </div>
        </ViewTransition>
      </Suspense>
    </section>
  )
}

function Inicio() {
  return <h2>Bienvenido a EMPRESA</h2>
}

function Departamentos() {
  const departamentos = useLoaderData<Departamento[]>()
  return (
    <ul className="simple">
      {departamentos.map((d) => (
        <li key={d.deptId}>
          {d.nombre} <span className="discreto">{d.localidad}</span>
        </li>
      ))}
    </ul>
  )
}

const rutas = createBrowserRouter([
  {
    element: <Marco />,
    children: [
      { path: '/', element: <Inicio /> },
      {
        path: '/departamentos',
        element: <Departamentos />,
        loader: async () => {
          const respuesta = await fetch(`${API}/departamentos`)
          const datos = await respuesta.json()
          return datos.contenido as Departamento[]
        },
      },
    ],
  },
])

export default function App() {
  return <RouterProvider router={rutas} />
}

Lo que medimos con React Router 8.5.1:

Acción Resultado
Pulsar un Link Sí anima: hacia-izquierda y desde-derecha
Botón Atrás del navegador No anima: ninguna llamada a startViewTransition

No es un fallo de tu código. La documentación de React lo explica: una transición iniciada desde el antiguo evento popstate debe acabar de forma síncrona, así que React se salta la animación. La solución que propone es que el enrutador use la Navigation API.

⚠️ Importante: <Link viewTransition> de React Router es otra cosa: el enrutador llama por su cuenta a document.startViewTransition, sin pasar por <ViewTransition>. Usa una de las dos vías, no ambas. Además, la prop useTransitions={false} de <RouterProvider> evita startTransition, y sin transición no hay animación.

Si escribes tu propio enrutador, la documentación añade un requisito: React espera a que acabe la navegación pendiente, y si esta depende de React, debes desbloquearla en useLayoutEffect. Con useEffect habría un bloqueo.


13. Soporte, accesibilidad y versiones anteriores

MDN indica que startViewTransition es Baseline 2025: funciona en las últimas versiones de los navegadores principales desde octubre de 2025. Hemos simulado un navegador sin esa API: React no da ningún error, y el cambio se aplica sin animación. Por eso es una mejora progresiva segura. Hoy solo funciona en el DOM, no en React Native.

Movimiento reducido

Hay personas a las que las animaciones les marean. Respeta siempre su preferencia, como hace el final del index.css:

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

Con prefers-reduced-motion activado medimos cero animaciones sobre los pseudoelementos, y la interfaz cambia igual.

Sin <ViewTransition>

En versiones anteriores puedes usar la API del navegador directamente. flushSync obliga a React a escribir el DOM dentro de la función, que es lo que el navegador necesita para tomar la segunda imagen:

import { useState } from 'react'
import { flushSync } from 'react-dom'

export default function App() {
  const [visible, setVisible] = useState(false)

  function alternar() {
    if (!document.startViewTransition) {
      setVisible((v) => !v)
      return
    }
    document.startViewTransition(() => {
      flushSync(() => setVisible((v) => !v))
    })
  }

  return (
    <>
      <div className="fila">
        <button onClick={alternar}>Alternar</button>
      </div>
      {visible && (
        <article className="tarjeta" style={{ viewTransitionName: 'ficha' }}>
          <h2>Ana García López</h2>
        </article>
      )}
    </>
  )
}

Funciona, pero fíjate en lo que pierdes: pones los nombres a mano, debes comprobar el soporte, flushSync hace la actualización síncrona y no se integra con Suspense.


14. browser(): solo en el navegador 🆕 19.3

🆕 Desde React 19.3. No existe en React 19.2 ni anteriores. Si usas una versión anterior: usa un estado montado que se activa en useEffect y pinta un sustituto hasta entonces. Pierdes el aviso onBrowserBailout y haces un render de más.

Este apartado también mira al módulo 23. Si tu aplicación se renderiza en el servidor, algunos componentes no pueden hacerlo: usan localStorage, window o la zona horaria del usuario. Lo habitual era esto:

const [montado, setMontado] = useState(false)
useEffect(() => setMontado(true), [])
if (!montado) return <p>Cargando...</p>

Es código repetitivo, y hace un render extra. browser(), de react-dom, sustituye a todo eso:

import { Suspense, use, useState } from 'react'
import { browser } from 'react-dom'

function Borrador() {
  use(browser('El borrador se guarda en localStorage.'))
  const [texto, setTexto] = useState(() => localStorage.getItem('borrador') ?? '')

  function cambiar(valor: string) {
    setTexto(valor)
    localStorage.setItem('borrador', valor)
  }

  return (
    <textarea
      aria-label="Borrador"
      value={texto}
      onChange={(e) => cambiar(e.target.value)}
    />
  )
}

export default function Pagina() {
  return (
    <main className="tarjeta">
      <h2>Nueva incidencia</h2>
      <p>Este texto sí viene en el HTML del servidor.</p>
      <Suspense fallback={<p className="discreto">Preparando el borrador...</p>}>
        <Borrador />
      </Suspense>
    </main>
  )
}

En el servidor, use(browser()) detiene el componente y deja el fallback de la frontera de Suspense más cercana. En el navegador devuelve undefined, y el componente sigue con normalidad. Compara las dos capturas: la primera es la página con JavaScript desactivado, y la segunda, ya hidratada, con el borrador recuperado al recargar.

HTML del servidor Tras hidratar
Página con el mensaje Preparando el borrador Página con el borrador escrito

Figura 11. El servidor envía el fallback. El navegador lo sustituye por el componente real.

Para saber qué componentes se quedan fuera del servidor, usa onBrowserBailout. Esta es la parte del servidor, con Node:

const { pipe } = renderToPipeableStream(<Pagina />, {
  onAllReady() {
    pipe(respuesta)
  },
  onBrowserBailout(error, info) {
    console.log((error as Error).cause, info.componentStack)
  },
})

Así queda en la consola del servidor:

bailout: Browser-only rendering was requested by `browser()`. | causa: El borrador se guarda en localStorage.
pila: at Borrador

Lo que comprobamos:

  • El motivo no viaja en el HTML, como indica la documentación. Es solo para tus registros.
  • Al hidratar no hubo errores: onRecoverableError no se llamó.
  • Sin frontera de Suspense, el render de servidor falla. Se llama a onShellError con The server render could not complete because client rendering was requested outside a Suspense boundary.

Otras reglas de la documentación: browser() por sí solo no hace nada, hay que pasarlo a use; no debes lanzarlo con throw; y debe llamarse desde un Client Component, no desde un Server Component. El argumento es opcional, y puede ser una cadena o una función. También sirve como motivo para abortar un render lento: abort(browser('El servidor ha tardado demasiado')).

Como use admite condiciones, puedes decidir sobre la marcha: es lo que pide el ejercicio 6.


15. Trusted Types 🔄 19.3

🔄 Cambia en React 19.3. dangerouslySetInnerHTML ya aceptaba objetos TrustedHTML antes, pero srcDoc no. Si usas una versión anterior: usa dangerouslySetInnerHTML con una política, que funciona igual desde 18.3, y asigna srcdoc con una ref en lugar de la prop.

En el módulo 02 viste que dangerouslySetInnerHTML es la puerta de entrada típica de los ataques XSS. Trusted Types es una defensa del navegador. Activas una cabecera CSP, y desde ese momento los puntos peligrosos del DOM, como innerHTML, rechazan las cadenas. Solo admiten objetos creados por una política, que es una función tuya.

La ventaja es que puedes auditar el código: en lugar de revisar cada innerHTML, revisas las políticas. La cabecera es esta (aquí, en index.html para probarla):

<meta
  http-equiv="Content-Security-Policy"
  content="require-trusted-types-for 'script'; trusted-types empresa"
/>

En producción, envíala como cabecera HTTP real. El segundo trozo limita qué nombres de política se pueden crear.

Una política en acción

TypeScript 7 no trae los tipos de trustedTypes en su lib.dom, aunque @types/react sí declara un TrustedHTML vacío y acepta __html: string | TrustedHTML. Puedes instalar @types/trusted-types, o declarar lo mínimo en src/trusted-types.d.ts:

interface PoliticaConfianza {
  createHTML(texto: string): TrustedHTML
}

interface Window {
  trustedTypes?: {
    createPolicy(
      nombre: string,
      reglas: { createHTML: (texto: string) => string },
    ): PoliticaConfianza
  }
}

Y este es el ejemplo: admite **negrita** y escapa todo lo demás. El escapar es deliberadamente simple, y en un proyecto real usarías una librería como DOMPurify dentro de la política.

import { useMemo, useState } from 'react'

function escapar(texto: string) {
  return texto
    .replaceAll('&', '&amp;')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;')
    .replaceAll('"', '&quot;')
}

// Único punto de la aplicación que convierte texto en HTML
const politica = window.trustedTypes!.createPolicy('empresa', {
  createHTML(texto) {
    return escapar(texto).replace(/\*\*(.+?)\*\*/g, '<b>$1</b>')
  },
})

function Nota({ texto }: { texto: string }) {
  const html = useMemo(() => politica.createHTML(texto), [texto])
  return <p dangerouslySetInnerHTML={{ __html: html }} />
}

export default function App() {
  const [texto, setTexto] = useState('Pedido **urgente** <img src=x onerror=alert(1)>')
  const [contador, setContador] = useState(0)

  return (
    <section className="tarjeta">
      <h2>Nota de pedido</h2>
      <textarea aria-label="Nota" value={texto} onChange={(e) => setTexto(e.target.value)} />
      <Nota texto={texto} />
      <button onClick={() => setContador((c) => c + 1)}>Repintar ({contador})</button>
    </section>
  )
}

Nota de pedido con urgente en negrita y la etiqueta img mostrada como texto inofensivo

Figura 12. La negrita funciona, y el intento de ataque con <img onerror> se muestra como texto.

Qué dice el blog y qué ocurre de verdad

El blog de React 19.3 afirma que antes React convertía siempre los valores a cadena con '' + value, lo que anulaba los objetos de confianza. La prueba en navegador matiza eso. Bajo la cabecera anterior, en Chromium 156:

Caso 18.3.1 19.0.9 a 19.2.9 19.3.1
__html con TrustedHTML Funciona Funciona Funciona
__html con cadena Rechazado Rechazado Rechazado
<iframe srcDoc> con TrustedHTML Rechazado Rechazado Funciona
Elemento <script> creado por React Rechazado Rechazado Rechazado

Así que innerHTML no había dejado de funcionar: dangerouslySetInnerHTML ya aceptaba estos objetos. El cambio real está en los atributos, como srcDoc, que antes se convertían a cadena.

⚠️ Importante: Un <script> en JSX sigue siendo rechazado en 19.3.1, incluso con TrustedScriptURL y con async. La causa es interna: la violación registrada es sobre innerHTML con el valor <script></script>, porque React crea el elemento así. Si necesitas cargar un script en una página con Trusted Types, créalo tú con document.createElement y asigna a src un valor de tu política.

Conserva el objeto entre renders

Hay un segundo cambio. Medimos un re-render del padre que deja __html igual, comprobando si el nodo interior seguía siendo el mismo:

__html 18.3.1 19.2.9 19.3.1
Mismo objeto entre renders Se conserva Se reasigna Se conserva
Objeto nuevo con el mismo texto Se reasigna Se reasigna Se reasigna

React compara por identidad, y cada llamada a createHTML devuelve un objeto nuevo. Si la llamas en cada render, destruyes y recreas ese DOM, y pierdes por ejemplo la selección de texto. Por eso el ejemplo usa useMemo: el botón Repintar no toca el nodo.


16. Transiciones independientes y otros cambios de 19.3

Transiciones independientes 🔄 19.3

🔄 Cambia en React 19.3. Hasta React 19.2 las transiciones simultáneas se agrupan y una lenta retiene a las demás. Si usas una versión anterior: no hay alternativa directa. Evita lanzar a la vez transiciones sin relación, o saca de la transición la que deba verse enseguida.

Este cambio ya está medido en el apartado 3 del módulo 15: una transición rápida pasa de unos 1000 ms de espera a 7 ms. Ten en cuenta que esa independencia no incluye las Actions asíncronas: como se midió en el módulo 16, dos Actions de 200 y 1000 ms se publican juntas al acabar la última en 19.0.9, 19.2.9 y 19.3.1.

Otros cambios pequeños

Cambio Versión Qué supone Dónde
submitter en el evento submit 🆕 19.3 e.submitter indica qué botón envió el formulario. Medido: undefined en 19.2.9. Antes, usa e.nativeEvent.submitter Módulo 08
onFullscreenChange y onFullscreenError 🆕 19.3 Medido: onFullscreenChange se dispara en 19.3.1. No está en @types/react 19.3.0 Aquí
Doble efecto al hidratar en Strict Mode 🔄 19.3 Corrige una regresión de 19.0 a 19.2 Módulo 09
useEffectEvent en memo y forwardRef 🔄 19.3 Ya lee valores actuales Módulo 09
Fragment Refs 🆕 19.3 Refs a un grupo de nodos Módulo 10
Aviso por use en condicional 🔄 19.3 Aviso nuevo; allí se mide cuándo aparece Módulo 15
<Context> en Server Components 🆕 19.3 Sin componente envoltorio Módulo 23
onReset tras una Server Action 🔄 19.3 Se dispara al reiniciar el formulario Módulo 23
useSyncExternalStore en Activity oculta 🔄 19.3 Según el registro de cambios, ya no se pierden cambios del almacén Módulo 12

17. Caso completo: panel de EMPRESA 🆕 19.3

🆕 Desde React 19.3. No existe en React 19.2 ni anteriores. Si usas una versión anterior: quita <ViewTransition> y addTransitionType. El panel sigue funcionando sin animaciones desde 19.2, y antes de 19.2 cambia <Activity> por display: none.

Este panel reúne casi todo el módulo. Las secciones viven en <Activity>, así que conservan el estado. El cambio de sección se anima según la dirección, con addTransitionType. Dentro de la plantilla, el avatar viaja a la ficha con share. Y si tienes activado el movimiento reducido, todo sigue funcionando sin animaciones.

import {
  Activity,
  Suspense,
  ViewTransition,
  addTransitionType,
  startTransition,
  use,
  useState,
} from 'react'

const API = 'http://localhost:3000/api'

type Seccion = 'plantilla' | 'pedidos'

interface Empleado {
  empId: number
  nombre: string
  apellidos: string
  oficio: string
  salario: number
}

interface Pedido {
  pedidoId: number
  fechaPedido: string
  estado: string
}

const cache = new Map<string, Promise<unknown>>()

function pedir<T>(ruta: string) {
  if (!cache.has(ruta)) {
    cache.set(ruta, fetch(`${API}/${ruta}`).then((r) => r.json()))
  }
  return cache.get(ruta) as Promise<T>
}

function Avatar({ empleado }: { empleado: Empleado }) {
  return (
    <ViewTransition name={`empleado-${empleado.empId}`} share="morfosis" default="none">
      <div className="miniatura">{empleado.nombre[0]}</div>
    </ViewTransition>
  )
}

function Plantilla() {
  const { contenido } = use(pedir<{ contenido: Empleado[] }>('empleados?tamano=6'))
  const [elegido, setElegido] = useState<Empleado | null>(null)

  function abrir(empleado: Empleado | null) {
    startTransition(() => setElegido(empleado))
  }

  if (elegido) {
    return (
      <div className="detalle">
        <Avatar empleado={elegido} />
        <div>
          <h2>
            {elegido.nombre} {elegido.apellidos}
          </h2>
          <p>
            {elegido.oficio}, {elegido.salario} €
          </p>
          <button onClick={() => abrir(null)}>Volver</button>
        </div>
      </div>
    )
  }

  return (
    <div className="rejilla">
      {contenido.map((e) => (
        <button key={e.empId} onClick={() => abrir(e)}>
          <Avatar empleado={e} />
          {e.nombre}
        </button>
      ))}
    </div>
  )
}

function Pedidos() {
  const { contenido } = use(pedir<{ contenido: Pedido[] }>('pedidos?tamano=6&orden=-fechaPedido'))
  return (
    <ul className="simple">
      {contenido.map((p) => (
        <li key={p.pedidoId}>
          Pedido {p.pedidoId}, {p.fechaPedido} <span className="discreto">{p.estado}</span>
        </li>
      ))}
    </ul>
  )
}

function Zona({ visible, children }: { visible: boolean; children: React.ReactNode }) {
  return (
    <Activity mode={visible ? 'visible' : 'hidden'}>
      <ViewTransition
        default="none"
        enter={{ avanza: 'entra-derecha', retrocede: 'entra-izquierda', default: 'none' }}
        exit={{ avanza: 'sale-izquierda', retrocede: 'sale-derecha', default: 'none' }}
      >
        <div>
          <Suspense fallback={<div className="esqueleto" />}>{children}</Suspense>
        </div>
      </ViewTransition>
    </Activity>
  )
}

export default function App() {
  const [seccion, setSeccion] = useState<Seccion>('plantilla')

  function ir(destino: Seccion) {
    if (destino === seccion) return
    startTransition(() => {
      addTransitionType(destino === 'pedidos' ? 'avanza' : 'retrocede')
      setSeccion(destino)
    })
  }

  return (
    <section className="tarjeta">
      <h2>Panel de EMPRESA</h2>
      <nav className="fila">
        <button aria-pressed={seccion === 'plantilla'} onClick={() => ir('plantilla')}>
          Plantilla
        </button>
        <button aria-pressed={seccion === 'pedidos'} onClick={() => ir('pedidos')}>
          Pedidos
        </button>
      </nav>
      <Zona visible={seccion === 'plantilla'}>
        <Plantilla />
      </Zona>
      <Zona visible={seccion === 'pedidos'}>
        <Pedidos />
      </Zona>
    </section>
  )
}

Panel durante el cambio de sección, con la ficha de empleado saliendo y los pedidos entrando

Figura 13. Cambio de Plantilla a Pedidos congelado a media animación. La ficha abierta sigue ahí cuando vuelves.

Las claves del diseño:

  • La <Activity> envuelve al <ViewTransition>, y este va antes de cualquier nodo. Así se activan enter y exit.
  • Suspense queda dentro de Zona, y la sección oculta se precarga: los pedidos ya están listos antes de entrar.
  • pedir memoriza las promesas por ruta, de modo que ocultar y mostrar no repite peticiones.
  • El nombre lleva el identificador: empleado-102 es único aunque haya una lista.

Comprobado en el navegador: la ficha abierta del empleado se conserva tras ir a Pedidos y volver, y la consola queda limpia.


18. Errores frecuentes y depuración

Síntoma Causa Solución
No se anima nada El setState no está en una transición Envuélvelo en startTransition
Se anima el cambio, pero no enter ni exit Hay un nodo del DOM antes del límite Pon <ViewTransition> primero
There are two <ViewTransition name=%s>... Nombre repetido Añade el identificador al nombre
No viaja el elemento compartido Un lado está fuera de la ventana, o hubo un fallback Comprueba la posición, o precarga los datos
El botón Atrás no anima La navegación llega por popstate Es lo esperado con enrutadores que no usan la Navigation API
Toda la lista se funde Hay un único límite Un <ViewTransition key> por fila
El evento onEnter no se dispara default="none" lo ha apagado Añade enter="auto"
Tras volver a una pestaña se repite la petición El efecto se recrea al mostrar Guarda los datos en una caché
Sigue sonando un vídeo oculto display: none no lo detiene Pausa en la limpieza de useLayoutEffect
Una ref es null en una Activity oculta Las refs se desconectan al ocultar Mide cuando esté visible
Render de servidor falla con browser() Falta la frontera Rodéalo con <Suspense>
TypeError en innerHTML Una cadena bajo Trusted Types Crea el valor con una política
Un nodo se recrea en cada render createHTML en cada render Usa useMemo

Para ver una animación con calma, abre las DevTools de Chromium, ve a Animations y reduce la velocidad. También puedes alargarlas un momento:

::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
  animation-duration: 3s !important;
}

19. Ejercicios

Ejercicio 1: Catálogo con dos pestañas

Crea dos pestañas, Productos y Clientes, con un mismo componente Listado que recibe la ruta de la API y tiene su propio filtro. El filtro de cada pestaña debe conservarse al cambiar a la otra.

Ver solución
import { Activity, useEffect, useState } from 'react'

const API = 'http://localhost:3000/api'

interface Fila {
  id: number
  nombre: string
}

function Listado({ ruta, campoId }: { ruta: string; campoId: string }) {
  const [filas, setFilas] = useState<Fila[]>([])
  const [filtro, setFiltro] = useState('')

  useEffect(() => {
    fetch(`${API}/${ruta}?tamano=100`)
      .then((r) => r.json())
      .then((datos) =>
        setFilas(
          datos.contenido.map((d: Record<string, unknown>) => ({
            id: d[campoId],
            nombre: d.nombre,
          })),
        ),
      )
  }, [ruta, campoId])

  const visibles = filas.filter((f) => f.nombre.toLowerCase().includes(filtro.toLowerCase()))

  return (
    <>
      <input
        aria-label={`Filtrar ${ruta}`}
        value={filtro}
        onChange={(e) => setFiltro(e.target.value)}
      />
      <ul className="simple">
        {visibles.slice(0, 5).map((f) => (
          <li key={f.id}>{f.nombre}</li>
        ))}
      </ul>
    </>
  )
}

export default function App() {
  const [activa, setActiva] = useState<'productos' | 'clientes'>('productos')

  return (
    <section className="tarjeta">
      <div className="fila">
        <button aria-pressed={activa === 'productos'} onClick={() => setActiva('productos')}>
          Productos
        </button>
        <button aria-pressed={activa === 'clientes'} onClick={() => setActiva('clientes')}>
          Clientes
        </button>
      </div>
      <Activity mode={activa === 'productos' ? 'visible' : 'hidden'}>
        <Listado ruta="productos" campoId="productoId" />
      </Activity>
      <Activity mode={activa === 'clientes' ? 'visible' : 'hidden'}>
        <Listado ruta="clientes" campoId="clienteId" />
      </Activity>
    </section>
  )
}

Cada Listado conserva su estado porque nunca se desmonta. Comprobado: tras escribir port, cambiar y volver, el filtro sigue ahí con 3 resultados.

Ejercicio 2: Sin peticiones repetidas

Muestra los departamentos en una Activity que se pueda ocultar y mostrar, sin que eso repita la petición.

Ver solución
import { Activity, Suspense, use, useState } from 'react'

const API = 'http://localhost:3000/api'

interface Departamento {
  deptId: number
  nombre: string
}

let promesa: Promise<{ contenido: Departamento[] }> | null = null

function cargar() {
  promesa ??= fetch(`${API}/departamentos`).then((r) => r.json())
  return promesa
}

function Departamentos() {
  const { contenido } = use(cargar())
  return (
    <ul className="simple">
      {contenido.map((d) => (
        <li key={d.deptId}>{d.nombre}</li>
      ))}
    </ul>
  )
}

export default function App() {
  const [ver, setVer] = useState(true)

  return (
    <section className="tarjeta">
      <button onClick={() => setVer((v) => !v)}>{ver ? 'Ocultar' : 'Mostrar'}</button>
      <Activity mode={ver ? 'visible' : 'hidden'}>
        <Suspense fallback={<p>Cargando...</p>}>
          <Departamentos />
        </Suspense>
      </Activity>
    </section>
  )
}

La petición sale de use con una promesa memorizada fuera del componente, y no de un efecto. Al mostrar de nuevo no se crea ninguna. Comprobado: cero peticiones al ocultar y mostrar. En una aplicación real, TanStack Query te da esto mismo con caducidad e invalidación.

Ejercicio 3: Panel de avisos animado

Crea un panel de avisos que entre y salga con las clases entra-abajo y sale-abajo, y que recuerde cuántos avisos has marcado como leídos cuando se cierra.

Ver solución
import { Activity, ViewTransition, startTransition, useState } from 'react'

function Avisos() {
  const [leidos, setLeidos] = useState(0)
  return (
    <ViewTransition default="none" enter="entra-abajo" exit="sale-abajo">
      <aside className="tarjeta">
        <h2>Avisos</h2>
        <p>Has marcado {leidos} como leídos.</p>
        <button onClick={() => setLeidos((n) => n + 1)}>Marcar uno</button>
      </aside>
    </ViewTransition>
  )
}

export default function App() {
  const [abierto, setAbierto] = useState(false)

  return (
    <>
      <div className="fila">
        <button onClick={() => startTransition(() => setAbierto((a) => !a))}>
          {abierto ? 'Cerrar avisos' : 'Abrir avisos'}
        </button>
      </div>
      <Activity mode={abierto ? 'visible' : 'hidden'}>
        <Avisos />
      </Activity>
    </>
  )
}

La Activity conserva el contador, y la transición activa enter y exit. El límite va dentro de Avisos, como primer elemento. Comprobado: aparecer-abajo al abrir, salir-abajo al cerrar, y el contador sigue en 1.

Ejercicio 4: Asistente con dirección

Crea un asistente de tres pasos con botones 1, 2 y 3. Al avanzar, el contenido debe deslizar hacia un lado, y al retroceder, hacia el otro, aunque saltes del 3 al 1.

Ver solución
import { ViewTransition, addTransitionType, startTransition, useState } from 'react'

const pasos = ['Datos del cliente', 'Líneas del pedido', 'Confirmación']

export default function App() {
  const [paso, setPaso] = useState(0)

  function ir(destino: number) {
    startTransition(() => {
      addTransitionType(destino > paso ? 'avanza' : 'retrocede')
      setPaso(destino)
    })
  }

  return (
    <section className="tarjeta">
      <h2>Nuevo pedido</h2>
      <div className="fila">
        {pasos.map((nombre, i) => (
          <button key={nombre} aria-pressed={i === paso} onClick={() => ir(i)}>
            {i + 1}
          </button>
        ))}
      </div>
      <ViewTransition
        key={paso}
        default="none"
        enter={{ avanza: 'entra-derecha', retrocede: 'entra-izquierda', default: 'none' }}
        exit={{ avanza: 'sale-izquierda', retrocede: 'sale-derecha', default: 'none' }}
      >
        <p>{pasos[paso]}</p>
      </ViewTransition>
    </section>
  )
}

La dirección se decide comparando el destino con el paso actual. Comprobado: del 1 al 3 se usa avanza, y del 3 al 1, retrocede.

Ejercicio 5: Ficha de cliente con salida del esqueleto

Al elegir un cliente, muestra un esqueleto y, cuando lleguen los datos, haz que el esqueleto salga y la ficha entre, cada uno con su animación.

Ver solución
import { Suspense, ViewTransition, startTransition, use, useState } from 'react'

const API = 'http://localhost:3000/api'

interface Cliente {
  clienteId: number
  nombre: string
  ciudad: string
}

const cache = new Map<number, Promise<Cliente>>()

function cargarCliente(id: number) {
  if (!cache.has(id)) {
    cache.set(id, fetch(`${API}/clientes/${id}`).then((r) => r.json()))
  }
  return cache.get(id)!
}

function Ficha({ id }: { id: number }) {
  const cliente = use(cargarCliente(id))
  return (
    <ViewTransition default="none" enter="entra-abajo">
      <article>
        <h2>{cliente.nombre}</h2>
        <p>{cliente.ciudad}</p>
      </article>
    </ViewTransition>
  )
}

function Esqueleto() {
  return (
    <ViewTransition default="none" exit="sale-abajo">
      <div className="esqueleto" />
    </ViewTransition>
  )
}

export default function App() {
  const [id, setId] = useState<number | null>(null)

  return (
    <section className="tarjeta">
      <div className="fila">
        {[1001, 1002, 1003].map((n) => (
          <button key={n} aria-pressed={n === id} onClick={() => setId(n)}>
            {n}
          </button>
        ))}
        <button onClick={() => startTransition(() => setId(null))}>Limpiar</button>
      </div>
      {id !== null && (
        <Suspense key={id} fallback={<Esqueleto />}>
          <Ficha id={id} />
        </Suspense>
      )}
    </section>
  )
}

Es la segunda colocación del apartado 10: cada lado tiene su propio límite. key={id} en Suspense evita que un cliente reutilice la frontera del anterior. Comprobado: una sola transición con salir-abajo y aparecer-abajo.

Ejercicio 6: Zona horaria con valor opcional

Escribe un hook useZonaHoraria(porDefecto?). Con valor por defecto, debe renderizarse en el servidor. Sin él, debe detectar la zona solo en el navegador.

Ver solución
function useZonaHoraria(porDefecto?: string) {
  if (porDefecto !== undefined) return porDefecto
  use(browser('El servidor no conoce la zona horaria del usuario.'))
  return Intl.DateTimeFormat().resolvedOptions().timeZone
}

function ZonaHoraria({ porDefecto }: { porDefecto?: string }) {
  const zona = useZonaHoraria(porDefecto)
  return <p>Zona horaria: {zona}</p>
}

// En la página
<ZonaHoraria porDefecto="Europe/Madrid" />
<Suspense fallback={<p>Detectando tu zona...</p>}>
  <ZonaHoraria />
</Suspense>

use es el único caso en que una llamada de este tipo puede ir tras un return condicional. Comprobado: el HTML del servidor contiene Europe/Madrid y, en la segunda, el fallback. Tras hidratar aparece la zona real.

Ejercicio 7: Vista previa con srcDoc

Bajo la cabecera de Trusted Types, crea una vista previa de un correo en un <iframe srcDoc>, con el texto escapado.

Ver solución
import { useMemo, useState } from 'react'

const politica = window.trustedTypes!.createPolicy('empresa', {
  createHTML: (texto) =>
    `<!doctype html><meta charset="utf-8"><pre>${texto.replaceAll('&', '&amp;').replaceAll('<', '&lt;')}</pre>`,
})

export default function App() {
  const [texto, setTexto] = useState('Hola <b>Ana</b>')
  const documento = useMemo(() => politica.createHTML(texto), [texto])

  return (
    <section className="tarjeta">
      <h2>Vista previa del correo</h2>
      <textarea aria-label="Correo" value={texto} onChange={(e) => setTexto(e.target.value)} />
      <iframe title="Vista previa" sandbox="" srcDoc={documento as unknown as string} />
    </section>
  )
}

srcDoc está tipado como string en @types/react 19.3.0, por lo que hace falta la conversión. Comprobado en 19.3.1: dentro del iframe se ve Hola <b>Ana</b> como texto. Solo funciona desde 19.3. El atributo sandbox vacío añade otra capa de protección.

Ejercicio 8 (propuesto): Navegación con dirección

Sobre el ejemplo del apartado 12, usa useNavigate con addTransitionType dentro de startTransition para que los botones “Anterior” y “Siguiente” deslicen en sentidos contrarios.

Ejercicio 9 (propuesto): Detalle de pedido compartido

Haz que el número de pedido de una lista viaje al título de su detalle con share. Asegúrate de que el detalle se precarga, para que no intervenga ningún fallback.

Ejercicio 10 (propuesto): Cancelar en servidor

Tras leer el módulo 23, crea un Server Component con una función memorizada con cache, pasa cacheSignal() a fetch y registra cuándo se aborta.


Resumen

Concepto API Idea clave
Ocultar conservando <Activity mode> 🆕 19.2 Guarda estado y DOM, limpia los efectos
Precarga <Activity mode="hidden"> Solo con datos que activan Suspense
Cancelar en servidor cacheSignal 🆕 19.2 Solo en Server Components
Animar cambios <ViewTransition> 🆕 19.3 Solo con transiciones, Suspense o valores diferidos
Disparadores enter, exit, update, share React elige según cómo cambia el árbol
Colocación Antes de cualquier nodo Si no, no hay enter ni exit
Clases ::view-transition-new(.clase) Mejor que seleccionar por nombre
Causa addTransitionType 🆕 19.3 Elige la animación según el motivo
Elemento compartido name único Un lado sale y el otro entra a la vez
JavaScript onEnter, instancia.new.animate Devuelve una limpieza
Router React Router 8 Anima enlaces, no el botón Atrás
Solo navegador use(browser()) 🆕 19.3 Exige <Suspense>
Trusted Types createPolicy 🔄 19.3 innerHTML ya funcionaba, srcDoc es lo nuevo
Identidad de __html useMemo Un objeto nuevo reasigna el DOM
Transiciones independientes 🔄 19.3 No incluye las Actions asíncronas