Saltar al contenido
< samuelsantana.dev />
Volver al BlogDos relojes: 21:00 en el navegador en Brasilia y 00:00 en el servidor en UTC, con el diff de fechas del error React #418 entre ellos.

React #418 solo después de las 21 h: en el servidor ya era mañana

Samuel Santana
Publicado el 02 de octubre de 2026
ReactNext.jsTesting

De día, nada. A partir de las 21 h, cada carga del panel y de la página de demostración de BolsoVerde, la app de control de ventas que construyo para pequeños vendedores en Brasil (bolsoverde.com), registraba el error #418 de React en los logs de producción: una falla de hidratación. A medianoche, desaparecía. En pantalla, ningún síntoma: ningún botón roto, ningún valor equivocado.

Un error con horario fijo es casi una confesión. Si empieza a las 21 h en Brasilia, empieza a medianoche en UTC.

La fecha que escribió el servidor

Todo error de hidratación tiene la misma forma: el HTML que generó el servidor no es el HTML que React genera en el navegador al tomar el control de la página. Desde React 18, una diferencia no se parchea nodo a nodo. React descarta el HTML del servidor hasta el <Suspense> más cercano y vuelve a renderizar toda esa parte en el navegador. La app no declara ningún <Suspense>, así que la parte rehecha era, en la práctica, la página entera.

Aquí, la diferencia era una fecha. El panel tiene un cajón de "nuevo gasto" que está en la página desde el primer render, oculto, para abrirse al instante cuando el usuario toca el botón. Su campo de fecha empezaba en "hoy":

function buildInitialState(): FormState {
  return { type: "EXPENSE", amountText: "", description: "", date: getCurrentDate() };
}

Y getCurrentDate() no tenía ningún bug. Devuelve el día local de quien la ejecuta, a propósito:

export function getCurrentDate(): string {
  const now = new Date();
  return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
}

El atajo habitual, new Date().toISOString().slice(0, 10), devuelve el día en UTC, que en Brasilia se convierte en mañana a las 21 h. Ese error ya lo había evitado.

Lo que no había considerado era quién ejecuta la función primero. En una página de Next.js renderizada en el servidor, el primero en montar ese componente no es el navegador del usuario: es el servidor. Y el servidor corre en UTC, que es lo habitual en servidores en la nube. Su día local no es el día local de nadie que use la app.

Poco antes de las 22 h del 25 de septiembre, en Brasilia, el servidor ya vivía en el 26 de septiembre. La diferencia que React señaló fue exactamente esa (esa pantalla estaba en portugués):

+ 25/09/2026
- 26/09/2026

El + es lo que renderizó el navegador; el -, lo que vino en el HTML del servidor. La fecha aparece como texto dentro del botón que abre el calendario, así que era una diferencia de contenido, no de atributo.

La misma causa aparecía en otro lugar, pero un mes a la vez. La página de resultados empieza en el mes actual; en la última noche de cada mes, el servidor escribía el mes siguiente en el encabezado y el navegador, el mes correcto.

Por qué ninguna prueba lo detectó

Las pruebas unitarias corren en un solo proceso, con un solo reloj. El servidor de desarrollo corre en la misma máquina que el navegador. En ambos casos, servidor y navegador compartían reloj y zona horaria, así que generaban la misma fecha a cualquier hora. El bug necesitaba dos cosas que nunca coinciden en desarrollo: dos zonas horarias distintas y una hora entre las 21 h y la medianoche.

Y no dolía de forma visible. React se recupera solo y sigue. La propia documentación resume el costo: en el mejor caso, un error de hidratación hace la página más lenta; en el peor, los manejadores de eventos pueden quedar asociados a los elementos equivocados. Aquí era el caso bueno, pero con dos costos reales. El primero es justamente el trabajo que el renderizado en el servidor existe para ahorrar: en cada carga durante esas tres horas, el navegador rehacía la página desde cero. El segundo es menos obvio. Un error que se repite cada noche se convierte en ruido en el monitoreo. El equipo aprende a ignorarlo, y el próximo error de hidratación, quizás uno del caso malo, llega escondido en medio de él.

Las correcciones tentadoras

Hay formas rápidas de hacer desaparecer el error. Funcionan en sentido estricto, pero no resuelven el problema.

suppressHydrationWarning en el elemento. Silencia el aviso, y ahí está el riesgo. La documentación de React es explícita: con él, React no intenta corregir el texto que no coincide. El HTML del servidor se queda, y el usuario vería la fecha de mañana en el campo. Cambiaría un error en la consola por un dato equivocado en pantalla. Además, la supresión solo vale un nivel por debajo del elemento, y el texto está dentro del selector de fecha compartido de la app. Habría que suprimir el aviso en un componente que usan todos los formularios, ocultando también las diferencias que sí quiero ver.

Desactivar el renderizado en el servidor del cajón (dynamic(..., { ssr: false })). Funciona, pero descarta el renderizado en el servidor de un componente entero para arreglar un campo.

Fijar la zona horaria del servidor en America/Sao_Paulo. Eso solo cambia el problema de lugar. La app también tiene versiones en inglés y en español; cualquier persona fuera de la zona horaria de Brasilia tendría el mismo bug, invertido. El reloj del servidor no es el reloj del usuario, y ninguna zona horaria fija convierte uno en el otro.

Enviar la zona horaria del usuario al servidor, en una cookie o en una preferencia de la cuenta. Parece la versión correcta de la idea anterior, pero choca con dos límites. La primera visita no trae esa información: la zona horaria del navegador solo se conoce después de que algún JavaScript se haya ejecutado en él. Y una preferencia guardada envejece: quien viaja sigue viendo el día de su casa hasta que alguien actualice la cuenta. Quien sabe qué día es para el usuario es el dispositivo que tiene en la mano, en ese momento.

La alternativa clásica que sí funciona es renderizar dos veces: un useState(false) que pasa a true en un useEffect, y la fecha solo aparece después. Funciona. Pero React ya tiene una API hecha para "un valor en el servidor, otro en el cliente", y la app ya usaba ese patrón en otros puntos, como el ancho de pantalla del menú.

El patrón que se quedó: snapshot de servidor nulo

useSyncExternalStore recibe tres funciones: una para suscribirse a cambios, una que lee el valor en el cliente y una que lee el valor en el servidor. Esta última se usa en el render del servidor y también durante la hidratación en el navegador. Si devuelve null, los dos lados renderizan el mismo HTML. Justo después de hidratar, React vuelve a renderizar con el valor del cliente.

El hook entero tiene menos de treinta líneas:

"use client";

import { useSyncExternalStore } from "react";
import { getCurrentDate, getCurrentMonth } from "@/lib/format";

// No hay nada que escuchar: el valor se vuelve a leer en cada render, lo que basta para una fecha.
function subscribe() {
  return () => {};
}

const onServer = () => null;

/**
 * La fecha local de hoy ("2026-09-25"), o null mientras el servidor renderiza y la página hidrata.
 *
 * El servidor corre en UTC: a partir de las 21 h en Brasilia, allí ya es mañana. Un HTML renderizado
 * con el "hoy" del servidor no coincidiría con el del navegador, y React descartaría el HTML del
 * servidor. Null en los dos lados los mantiene iguales; la fecha del navegador llega justo después.
 */
export function useToday(): string | null {
  return useSyncExternalStore(subscribe, getCurrentDate, onServer);
}

/** El mes local ("2026-09"), null en el servidor por el mismo motivo que useToday. */
export function useCurrentMonth(): string | null {
  return useSyncExternalStore(subscribe, getCurrentMonth, onServer);
}

Dos detalles que importan

El snapshot es un string. React compara lecturas sucesivas con Object.is. Dos llamadas a getCurrentDate() en el mismo día devuelven strings iguales, así que el valor es estable. Si el hook devolviera un new Date(), cada lectura sería un objeto nuevo, y React avisaría que el resultado de getSnapshot debe estar cacheado para no entrar en un bucle.

La suscripción no hace nada. No existe un evento "cambió el día" que escuchar; el valor se vuelve a leer en cada render. Eso tuvo un efecto secundario bueno. Antes, la fecha por defecto iba al estado inicial del formulario y quedaba congelada: quien abría la app a las 23:50 y registraba un gasto a las 0:10 lo registraba con la fecha de ayer. Ahora el valor por defecto sigue al calendario hasta que el usuario elige una fecha.

Null obliga a decidir qué mostrar antes de saber el día

El tipo string | null es lo que hace segura la corrección. Cada componente que usaba la fecha ahora tiene que decir qué muestra mientras no existe, y el compilador los señala a todos.

En el formulario, el estado pasó a guardar solo lo que eligió el usuario. La fecha queda indefinida hasta que elija una, y el valor mostrado se resuelve en cada render (código simplificado):

// Antes: la fecha iba al estado inicial, calculada por quien renderizara primero.
const [state, setState] = useState<FormState>({ type: "EXPENSE", date: getCurrentDate() });

// Después: el estado guarda solo la elección del usuario; el "hoy" viene del hook.
const [state, setState] = useState<FormState>({ type: "EXPENSE" }); // date: undefined
const today = useToday();
const date = state.date ?? today ?? "";

El campo solo queda vacío en el HTML del servidor, dentro de un cajón cerrado que nadie ve.

En la página de resultados, el mes actual viene del hook, y no se busca nada antes de que exista:

const currentMonth = useCurrentMonth();
// Indefinido hasta que el usuario navega a otro mes.
const [ownMonth, setMonth] = useState<string | undefined>(undefined);
const month = controlledMonth ?? ownMonth ?? currentMonth;

useEffect(() => {
  if (!month) return; // todavía hidratando: no hay mes que buscar
  // ...busca los resultados del mes
}, [fetchStatement, month]);

Mientras el mes es null, el encabezado queda sin etiqueta, como el panel ya quedaba mientras cargaban los datos, y navegar entre meses, recargar y exportar no hacen nada. Eso dura solo hasta el render justo después de la hidratación, y en ningún momento aparece un mes equivocado.

Reproducirlo a propósito

Corregir sin reproducir sería apostar. El bug necesita el servidor y el navegador en zonas horarias distintas, así que forcé a los dos a no coincidir:

  • el servidor de desarrollo arrancó con TZ=UTC npm run dev;
  • el navegador de Playwright quedó en America/Sao_Paulo;
  • para el cambio de mes, sin esperar al día 30 a las 21 h, moví solo el reloj del navegador con page.clock.setFixedTime.
// crawl-hydration.mjs. Ejecutar con el servidor en UTC: TZ=UTC npm run dev
import { chromium } from "@playwright/test";

const BASE_URL = "http://localhost:3031";
const PAGES = ["/", "/demo", "/resultado"]; // en mi caso, 24 rutas

const browser = await chromium.launch();
const context = await browser.newContext({ timezoneId: "America/Sao_Paulo" });
// Rutas con sesión: inyectar la cookie de sesión con context.addCookies(...)
const page = await context.newPage();

// Opcional: mover solo el reloj del navegador. El servidor sigue con el reloj real.
await page.clock.setFixedTime(new Date("2026-08-20T12:00:00-03:00"));

const failures = [];
page.on("pageerror", (error) => failures.push(`${page.url()}\n${error.message}`));

for (const path of PAGES) {
  await page.goto(`${BASE_URL}${path}`);
  await page.waitForLoadState("networkidle");
}

console.log(failures.length ? failures.join("\n\n") : "ninguna diferencia");
await browser.close();

Un detalle: en mi crawler, la diferencia llegó como pageerror, no como console.error. Un script que solo escuchara la consola habría dicho que todo estaba bien.

El crawler recorrió 24 páginas, 8 públicas y 16 con sesión iniciada en una cuenta de prueba. Con el reloj real, poco antes de las 22 h, fallaron exactamente dos: el panel y la demostración, con +25/09/2026 -26/09/2026. Con el reloj del navegador movido a agosto, la página de resultados también falló, con +Agosto 2026 -Setembro 2026. Ninguna otra. Después de la corrección, el mismo crawler no encontró diferencias en ninguna de las 24, con cualquiera de los dos relojes.

El hook también tiene pruebas, pero demuestran otra cosa. Esta garantiza que el HTML del servidor nunca lleva la fecha, ni siquiera en el peor momento del mes:

function Probe() {
  const today = useToday() ?? "no date";
  const month = useCurrentMonth() ?? "no month";
  return <p>{`${today} / ${month}`}</p>;
}

beforeEach(() => {
  vi.useFakeTimers({ toFake: ["Date"] });
  // 22:30 en Brasilia el último día del mes: en UTC ya es octubre.
  vi.setSystemTime(new Date("2026-09-30T22:30:00-03:00"));
});

it("no escribe nada que dependa de la fecha en el HTML del servidor", () => {
  expect(renderToString(<Probe />)).toContain("no date / no month");
});

La prueba unitaria demuestra el contrato del hook. Lo que demostró que el bug había desaparecido fue el crawler, porque solo él corre con dos relojes distintos.

Del crawler al CI

Un crawler ejecutado una vez demuestra que el bug desapareció ese día. No impide que el próximo componente ponga un getCurrentDate() en su primer render. Para eso, la diferencia entre los relojes tenía que pasar a formar parte de la suite que corre en cada pull request.

La zona horaria sola no sirve en el CI: la diferencia solo existe después de las 21 h, y el pipeline corre a cualquier hora. La salida es la misma que en la prueba del cambio de mes, solo que permanente. El servidor se queda con el reloj real, y el navegador retrocede 45 días. Más de 31 días garantiza que los dos difieren en el día y en el mes, a cualquier hora, en cualquier fecha. En un build de producción el error llega minificado, apuntando a react.dev/errors/418, de ahí la expresión regular:

const BROWSER_CLOCK_SHIFT_MS = 45 * 24 * 60 * 60 * 1000;

// Los builds de producción reportan la diferencia como un error minificado, con enlace a react.dev/errors.
const HYDRATION_ERROR = /hydrat|react\.dev\/errors\/(418|423|425)\b/i;

async function hydrationErrorsOn(page: Page, path: string): Promise<string[]> {
  const errors: string[] = [];
  page.on("pageerror", (error) => {
    if (HYDRATION_ERROR.test(error.message)) errors.push(error.message);
  });

  // El servidor se queda con el reloj real; el navegador retrocede 45 días.
  await page.clock.setFixedTime(new Date(Date.now() - BROWSER_CLOCK_SHIFT_MS));
  await page.goto(path);
  await page.waitForLoadState("networkidle");
  return errors;
}

for (const path of SIGNED_IN_PAGES) {
  test(`${path} hydrates without a mismatch`, async ({ page }) => {
    expect(await hydrationErrorsOn(page, path)).toEqual([]);
  });
}

El spec recorre 28 páginas, 12 públicas y 16 con sesión iniciada. Una prueba de regresión solo vale si falla cuando el bug vuelve, así que lo reintroduje a propósito: hice que el snapshot de servidor del hook volviera a leer el reloj del servidor. Fallaron exactamente cinco páginas: el panel, la demostración en los tres idiomas y la página de resultados, las mismas que había resuelto la corrección original. Con el hook restaurado, pasaron las 28.

Hubo una ironía en el camino. Desde julio, la suite E2E ya fijaba el reloj del navegador en junio de 2025 en las pruebas del panel, para que coincidiera con los datos de ejemplo de los servicios simulados. La condición del bug estaba montada en cada ejecución de la suite. Solo faltaba que alguien escuchara el error.

Por qué importa

"Hoy" parece un valor, pero es una pregunta incompleta: ¿hoy dónde? En el navegador, la respuesta es obvia. En el servidor no existe un usuario; existe una máquina en una zona horaria que nadie eligió pensando en quien lee la página. Cualquier cosa derivada del reloj que entre en el primer HTML es una suposición del servidor sobre el día de otra persona.

La regla que quedó en el proyecto es corta: lo que depende de la hora actual y aparece antes de que lleguen los datos viene del navegador, y el servidor renderiza "todavía no lo sé". La lección más general va más allá de las fechas. Un bug que solo existe cuando dos entornos no coinciden no aparece en ningún entorno donde coinciden. Para verlo, hay que forzar la diferencia en lugar de esperar a que den las 21 h. Y, una vez corregido, esa diferencia tiene que quedarse en el CI, para que el próximo bug de la misma familia rompa un pull request y no la producción.

Comentarios

Cargando comentarios...