Гидратация и синхронизация локали

Гидратация интернационализации в приложениях на базе FormatJS тесно связана с синхронизацией локали между серверным и клиентским рендерингом. При серверном рендеринге (SSR) формируется HTML, уже содержащий локализованный текст, числа, даты и валюты. На клиенте происходит повторное «оживление» этого HTML (hydration), и любое несоответствие локали или набора сообщений приводит к расхождению между серверным и клиентским деревьями.

В SSR-приложениях локаль используется дважды: сначала на сервере для генерации HTML, затем на клиенте для восстановления состояния интерфейса. Библиотека React Intl, входящая в экосистему FormatJS, строит работу через IntlProvider, который передаёт локаль и сообщения во все компоненты.

Ключевая проблема возникает, когда:

  • сервер рендерит locale = "ru"
  • клиент инициализируется с locale = "en"
  • либо набор сообщений различается по ключам или структуре

В таком случае React фиксирует mismatch при гидратации.

Синхронизация локали между сервером и клиентом

Стабильность гидратации зависит от того, насколько строго согласованы входные параметры интернационализации.

Основные источники локали:

  • HTTP-заголовок Accept-Language
  • cookie пользователя
  • параметр маршрута (/ru/home)
  • настройки профиля пользователя
  • localStorage (только на клиенте)

На сервере локаль должна быть определена до рендера React-дерева, чтобы IntlProvider получил идентичные значения.

Пример серверной подготовки:

const locale = negotiateLocale(request.headers["accept-language"]);

const messages = loadMessages(locale);

const html = renderToString(
  <IntlProvider locale={locale} messages={messages}>
    <App />
  </IntlProvider>
);

На клиенте критически важно использовать тот же locale и те же messages:

const locale = window.__INITIAL_LOCALE__;
const messages = window.__INITIAL_MESSAGES__;

hydrateRoot(
  document.getElementById("root"),
  <IntlProvider locale={locale} messages={messages}>
    <App />
  </IntlProvider>
);

Любое отличие приведёт к повторной генерации текста и потенциальному разрушению гидратации.

Проблема различий сообщений между сервером и клиентом

FormatJS использует ключевые сообщения:

{
  "welcome.message": "Добро пожаловать",
  "cart.items": "Товаров: {count}"
}

Если на сервере набор сообщений включает ключ cart.items, а на клиенте он отсутствует или имеет другую структуру, возникают:

  • различия в DOM
  • fallback на message id
  • предупреждения о mismatch при hydration

Особенно критично динамическое подгружение переводов после первичного рендера.

Стратегии синхронной загрузки переводов

Стабильная гидратация требует, чтобы сообщения были доступны ДО рендера клиента.

Используются следующие подходы:

1. Инлайн-сериализация сообщений

Сервер передаёт JSON прямо в HTML:

<script>
  window.__INITIAL_MESSAGES__ = {
    "welcome.message": "Добро пожаловать"
  };
</script>

2. Предзагрузка через bundler

Переводы импортируются статически:

import ru from "./locales/ru.json";
import en from "./locales/en.json";

3. Lazy-loading с блокировкой рендера

Если сообщения грузятся асинхронно, рендер откладывается:

const messages = await loadMessages(locale);

hydrateRoot(
  root,
  <IntlProvider locale={locale} messages={messages}>
    <App />
  </IntlProvider>
);

Согласование даты, времени и чисел при гидратации

FormatJS опирается на Intl API браузера и Node.js. Несмотря на стандарт, поведение может различаться:

  • разные ICU версии в Node и браузере
  • различия в локалях окружения
  • разные временные зоны

Пример потенциального расхождения:

formatDate(new Date(), {
  year: "numeric",
  month: "long"
});

На сервере может использоваться UTC, на клиенте — локальная временная зона пользователя.

Для стабилизации применяется явная фиксация timezone:

formatDate(date, {
  timeZone: "Europe/Moscow"
});

Причины hydration mismatch в FormatJS

Основные источники ошибок:

  • различие locale между SSR и CSR
  • несовпадение messages
  • использование Math.random() внутри форматируемых строк
  • динамическая генерация сообщений
  • различие временных зон
  • lazy-load переводов после первого render

Даже если визуально текст совпадает, React может фиксировать различия в текстовых нодах.

Консистентность plural rules и rich formatting

Pluralization в FormatJS зависит от локали:

intl.formatMessage(
  { id: "cart.items" },
  { count: 3 }
);

При смене локали меняется не только текст, но и логика выбора формы:

  • ru: 1 товар / 2 товара / 5 товаров
  • en: 1 item / 2 items / 5 items

Если сервер и клиент используют разные локали, DOM-структура может измениться.

Rich text formatting и стабильность DOM

FormatJS поддерживает вложенные React-элементы:

intl.formatMessage(
  {
    id: "terms",
    defaultMessage: "Принять <b>условия</b>"
  },
  {
    b: chunks => <strong>{chunks}</strong>
  }
);

При гидратации важно, чтобы:

  • структура React-элементов совпадала
  • порядок токенов не изменялся
  • сообщения не пересобирались заново на клиенте

Любая нестабильность приводит к различию виртуального DOM.

Синхронизация локали через URL и маршрутизацию

Одним из устойчивых подходов является привязка локали к маршруту:

/ru/products
/en/products

Это позволяет серверу всегда однозначно определить контекст:

const locale = req.path.split("/")[1];

Такой подход уменьшает риск рассинхронизации между слоями приложения.

Кэширование и влияние на локализацию

CDN и серверное кэширование могут сохранять HTML, сгенерированный для одной локали, и отдавать его пользователю с другой локалью.

Это приводит к:

  • отображению чужого языка
  • некорректной гидратации
  • частичному перерендеру React

Для предотвращения используется разделение кэша по ключу:

Cache-Key: /page + locale

Изоляция IntlProvider как точка стабильности

IntlProvider должен находиться максимально высоко в дереве компонентов:

<IntlProvider locale={locale} messages={messages}>
  <App />
</IntlProvider>

Если его размещать глубже, часть компонентов может получить один набор локали, а часть — другой, что приводит к несогласованной гидратации.

Поведение fallback при отсутствии сообщений

FormatJS использует fallback-стратегии:

  • если message отсутствует → используется defaultMessage
  • если нет defaultMessage → используется id

При SSR и CSR разные fallback-ветки могут сформировать разный текст, что ломает гидратацию.

Асинхронная смена локали после гидратации

После первичной гидратации возможна смена языка:

setLocale("en");

Это вызывает повторный рендер всего дерева IntlProvider. Если не управлять этим процессом, возможны:

  • мигание интерфейса
  • частичная перерисовка
  • расхождение дат и чисел на мгновение

Типичные архитектуры стабильной синхронизации

Используются два основных паттерна:

1. Server-driven locale

Сервер полностью контролирует локаль и передаёт её клиенту без изменений.

2. Client-hydrated but server-locked locale

Сервер фиксирует локаль в HTML, клиент не имеет права её менять до следующей навигации.

Оба подхода направлены на устранение несоответствий между SSR и CSR.

Отладка проблем гидратации в i18n слое

Диагностика включает:

  • сравнение locale на сервере и клиенте
  • проверку сериализованных сообщений
  • анализ DOM mismatch warnings
  • проверку ICU версии
  • логирование результата formatMessage на обеих сторонах

Критически важно проверять не только текст, но и структуру React-элементов, которые генерируются через форматирование сообщений.