В классических версиях React (16–17) серверный рендеринг страдал от фундаментального архитектурного дефекта, известного в индустрии как "Зловещая долина SSR" (The Uncanny Valley of SSR). Пользователь мгновенно получал сгенерированный сервером HTML-документ, визуально наблюдал кнопки, формы ввода и навигационные ссылки, однако интерфейс оставался абсолютно мертвым: клики игнорировались, выпадающие меню не раскрывались, а скролл страницы подергивался.
Причиной этого была монолитная модель гидратации:
React 19 решает эту проблему благодаря архитектуре Selective Hydration (Селективная гидратация), глубоко интегрированной с границами <Suspense>.
Вместо монолитного процесса гидратация разбивается на независимые кванты с поддержкой двух фундаментальных механизмов:
В архитектуре React 19 каждая граница <Suspense> выступает в роли изолирующего острова гидратации. Сервер размечает границы Suspense специальными HTML-комментариями (<!--$?--> для ожидающих блоков и <!--$--> для отрендеренных).
[ Серверный HTML загружен в браузер ]
├── <Header /> --> Чанк JS загружен: [ Гидрирован (Interactive) ]
├── <Suspense> (Остров Товара) --> Чанк JS загружен: [ Фоновая гидратация... ]
└── <Suspense> (Остров Отзывов) --> Чанк JS еще в сети: [ Ждет скачивания JS... ]
+----------------- [ Действие пользователя: КЛИК! ] -----------------+
Пользователь кликает по кнопке "Добавить в корзину" в [ Острове Товара ]!
1. Глобальный перехватчик событий на document ловит click/pointerdown.
2. Движок Fiber определяет, что узел находится в состоянии dehydrated.
3. React эскалирует приоритет [ Острова Товара ] до SyncLane.
4. Текущая фоновая работа прерывается; Остров Товара гидрируется немедленно.
5. Механизм Event Replay повторно воспроизводит перехваченный клик.
6. Состояние корзины обновляется без заметной пользователю задержки!
+----------------------------------------------------------------------+Селективная гидратация обеспечивает два критических преимущества:
React.lazy), начинает гидрироваться немедленно, даже если код для других частей страницы еще находится в пути.Браузер получает потоковый HTML с комментариями <!--$?--> и монтирует начальное статическое DOM-дерево.
Браузер асинхронно скачивает JavaScript-файлы независимых компонентов через динамические импорты React.lazy.
React оживляет доступные блоки в порядке очереди в моменты простоя процессора на полосе IdleLane (кванты по 5 мс).
Пользователь кликает по негидрированному острову. Корневой слушатель перехватывает событие и ставит его в очередь.
Целевой блок немедленно гидрируется на полосе SyncLane, после чего сохраненное событие программно воспроизводится.
Что произойдет в React 19, если пользователь кликнет по кнопке внутри <Suspense>, чей JavaScript-код уже загрузился, но фоновая гидратация еще не дошла до этого блока?
<Suspense> — вся остальная страница сохраняет серверный HTML.Браузер создает DOM-ноды с маркерами <!--$?-->. React регистрирует глобальные слушатели событий на document.
По мере загрузки чанков React обходит деревья внутри Suspense в фоновом режиме на низком приоритете IdleLane.
Пользователь кликает по негидрированному элементу. Слушатель захватывает SyntheticEvent и сохраняет в queuedEvents.
Целевой остров получает наивысший SyncLane. Движок синхронно завершает гидратацию и запускает replayEvent().
В ядре react-dom модуль ReactDOMEventReplayer регистрирует корневые слушатели событий (click, mousedown, mouseup, keydown, touchstart, focusin) на этапе первичной инициализации контейнера.
Дискретные события отличаются от непрерывных (continuous):
click, keydown, pointerdown) сохраняются в очередь и воспроизводятся, так как представляют осмысленное намерение пользователя.mousemove, scroll, pointermove) не буферизуются, чтобы не вызывать задержек и лавинообразных перерисовок устаревших координат курсора.interface QueuedDiscreteEvent {
blockedOn: FiberNode | null; // Узел Fiber, гидратации которого ждет событие
domEventName: string;
event: Event;
targetContainer: Node;
}
const queuedDiscreteEvents: QueuedDiscreteEvent[] = [];
export function dispatchDiscreteEvent(
domEventName: string,
event: Event,
targetContainer: Node
): void {
const target = event.target as Node | null;
const targetFiber = getFiberFromNode(target);
// Если узел еще не гидрирован (находится в очереди ожидания dehydrated)
if (targetFiber !== null && isFiberDehydrated(targetFiber)) {
// 1. Блокируем стандартное браузерное поведение и всплытие
event.preventDefault();
event.stopPropagation();
// 2. Сохраняем событие в очередь ожидания
queuedDiscreteEvents.push({
blockedOn: targetFiber,
domEventName,
event,
targetContainer,
});
// 3. ЭСКАЛАЦИЯ ПРИОРИТЕТА: форсируем немедленную гидратацию этого поддерева!
// Узел переносится из IdleLane в SyncLane
escalateHydrationPriority(targetFiber);
return;
}
// Обычная синхронная обработка для уже гидрированных узлов
dispatchEvent(domEventName, event, targetContainer);
}
export function replayDiscreteEventsForFiber(hydratedFiber: FiberNode): void {
for (let i = 0; i < queuedDiscreteEvents.length; i++) {
const queued = queuedDiscreteEvents[i];
if (queued.blockedOn === hydratedFiber) {
// Воспроизведение сохраненного события на уже интерактивном DOM
queued.targetContainer.dispatchEvent(queued.event);
queuedDiscreteEvents.splice(i, 1);
i--;
}
}
}Ниже приведен продакшн-пример карточки товара, демонстрирующий изоляцию критических зон интерактивности от медленных фоновых блоков с помощью <Suspense> и селективной гидратации:
import React, { Suspense, lazy, useState } from 'react';
// Ленивая подгрузка тяжелых интерактивных модулей (отдельные JS-чанки)
const DynamicColorCustomizer = lazy(() => import('./DynamicColorCustomizer'));
const HeavyReviewSection = lazy(() => import('./HeavyReviewSection'));
const InteractiveSimilarProducts = lazy(() => import('./InteractiveSimilarProducts'));
interface ProductProps {
id: string;
name: string;
price: number;
initialStock: number;
}
// 1. Критический интерактивный компонент: кнопка покупки
function BuyActionPanel({ price, initialStock }: { price: number; initialStock: number }) {
const [stock, setStock] = useState(initialStock);
const [isAdded, setIsAdded] = useState(false);
const handlePurchase = () => {
if (stock > 0) {
setStock((s) => s - 1);
setIsAdded(true);
}
};
return (
<div className="p-4 bg-bg-elevated border border-border rounded-lg space-y-3">
<div className="flex justify-between items-center">
<span className="text-2xl font-bold text-accent-cyan">${price}</span>
<span className="text-xs text-text-muted">В наличии: {stock} шт.</span>
</div>
<button
type="button"
onClick={handlePurchase}
disabled={stock === 0}
className="w-full py-3 bg-accent-cyan text-bg-surface font-bold rounded-md hover:opacity-90 disabled:opacity-40 transition-opacity"
>
{isAdded ? 'Добавлено в корзину!' : 'Купить в 1 клик'}
</button>
</div>
);
}
export function ProductCommerceView({ product }: { product: ProductProps }) {
return (
<div className="max-w-4xl mx-auto p-6 space-y-8">
<header className="space-y-2">
<h1 className="text-3xl font-extrabold text-text-primary">{product.name}</h1>
<p className="text-sm text-text-muted">Артикул: {product.id}</p>
</header>
{/*
ЗОНА 1: Критический блок покупки.
Не обернут в lazy, гидрируется в первую очередь!
*/}
<BuyActionPanel price={product.price} initialStock={product.initialStock} />
{/*
ЗОНА 2: Кастомизация цвета.
Изолирована границей Suspense. Если пользователь кликнет по цветам,
React мгновенно гидрирует этот блок вне общей очереди!
*/}
<Suspense fallback={<div className="h-24 bg-bg-elevated/40 rounded animate-pulse" />}>
<DynamicColorCustomizer productId={product.id} />
</Suspense>
{/*
ЗОНА 3: Тяжелый блок отзывов.
Будет гидрироваться в фоновом режиме (IdleLane) в последнюю очередь,
не задерживая готовность кнопок покупки.
*/}
<Suspense fallback={<div className="h-64 bg-bg-elevated/40 rounded animate-pulse" />}>
<HeavyReviewSection productId={product.id} />
</Suspense>
{/* ЗОНА 4: Рекомендации */}
<Suspense fallback={<div className="h-48 bg-bg-elevated/40 rounded animate-pulse" />}>
<InteractiveSimilarProducts productId={product.id} />
</Suspense>
</div>
);
}В классическом React при несовпадении клиентского и серверного HTML (например, расхождение локалей или времени) движок сбрасывал серверный DOM всей страницы и запускал медленный повторный рендер клиента от самого корня.
В React 19 благодаря границам <Suspense> сбой гидратации локализуется:
[ Страница: Root Document ]
├── <Header /> --> Гидратация успешна: DOM сохранен
├── <Suspense> (Остров виджета) --> ОШИБКА: Hydration Mismatch!
│ React локально сбрасывает ТОЛЬКО этот остров
│ и перерисовывает его на клиенте.
└── <Footer /> --> Гидратация успешна: DOM сохраненОстальная разметка документа не страдает от визуальных скачков и не тратит процессорные мощности на повторный парсинг.
Почему React перехватывает и ставит в очередь воспроизведения именно дискретные события (click, pointerdown, keydown), но не непрерывные (mousemove, scroll)?
Для отслеживания задержек интерактивности, безопасной обработки клиентских значений (таймзоны, window.innerWidth) и исключения Hydration Mismatch создадим защитный компонент-барьер:
import React, {
useState,
useEffect,
Suspense,
useSyncExternalStore,
type ReactNode,
} from 'react';
interface HydrationBoundaryProps {
children: ReactNode;
fallback: ReactNode;
boundaryName: string;
warnThresholdMs?: number;
}
// Пустая подписка для детерминированного определения фазы гидратации
const emptySubscribe = () => () => {};
/**
* Хук для безопасного определения клиентского контекста
* через useSyncExternalStore (на сервере возвращает false, на клиенте true)
*/
export function useIsClient(): boolean {
return useSyncExternalStore(
emptySubscribe,
() => true,
() => false
);
}
/**
* HydrationBoundary изолирует клиентские вычисления от SSR
* и отслеживает длительность перехода в интерактивное состояние.
*/
export function HydrationBoundary({
children,
fallback,
boundaryName,
warnThresholdMs = 50,
}: HydrationBoundaryProps) {
const isClient = useIsClient();
const [hydrationTime, setHydrationTime] = useState<number | null>(null);
useEffect(() => {
const startTime = performance.now();
// Измерение квантованного времени оживления
requestAnimationFrame(() => {
const duration = performance.now() - startTime;
setHydrationTime(duration);
if (duration > warnThresholdMs) {
console.warn(
`[Hydration Warning] Граница "${boundaryName}" гидрировалась ${duration.toFixed(
1
)} мс (порог: ${warnThresholdMs} мс). Рекомендуется декомпозиция.`
);
}
});
}, [boundaryName, warnThresholdMs]);
return (
<Suspense fallback={fallback}>
<div
data-hydration-boundary={boundaryName}
data-hydrated={isClient ? 'true' : 'false'}
>
{/* До гидратации отображается стабильный серверный fallback */}
{isClient ? children : fallback}
</div>
</Suspense>
);
}Если разработчик не оборачивает независимые тяжелые виджеты (интерактивные карты, сложные гриды, редакторы) в границы <Suspense>, селективная гидратация деградирует обратно в монолитную. React вынужден объединить все компоненты в единый гигантский блок гидратации, блокируя интерактивность всего экрана.
Вызов функций Date.now(), new Date().toLocaleTimeString() или Math.random() непосредственно в теле рендера гарантирует несовпадение серверного HTML с клиентским деревом. Это порождает ошибку Hydration failed because the initial UI does not match what was rendered on the server и вынуждает браузер перерисовывать узел.
// ❌ ПЛОХО: серверное время не совпадет со временем клиента при гидратации
export function LiveClock() {
return <span>{new Date().toLocaleTimeString()}</span>; // Hydration Mismatch!
}
// ✅ ПРАВИЛЬНО: чтение динамического времени строго после завершения монтирования
export function LiveClock() {
const [time, setTime] = useState<string | null>(null);
useEffect(() => {
setTime(new Date().toLocaleTimeString());
const timer = setInterval(() => {
setTime(new Date().toLocaleTimeString());
}, 1000);
return () => clearInterval(timer);
}, []);
return <span>{time ?? '--:--:--'}</span>;
}Размещение suppressHydrationWarning на тегах <html>, <body> или корневом контейнере отключает предупреждения линтера React о расхождениях для всех дочерних элементов. Это маскирует реальные архитектурные баги и утечки состояния. Атрибут допустим исключительно на точечных терминальных текстовых узлах (например, на отображении форматированной даты отметки времени).
Подключение внешних виджетов (Google Translate, расширения автозаполнения паролей, инъекции баннеров), модифицирующих DOM-структуру HTML до того, как React завершит селективную гидратацию, разрушает соответствие узлов и ломает дерево Fiber с ошибкой NotFoundError: Failed to execute 'removeChild' on 'Node'.
Ветвление рендера вида typeof window !== 'undefined' ? <ClientWidget /> : <ServerSkeleton /> приводит к тому, что на сервере генерируется один HTML, а клиент при первичном обходе ожидает совершенно другое дерево элементов. Это мгновенно вызывает ошибку Hydration Mismatch. Для клиентского рендеринга следует использовать useEffect или хук на базе useSyncExternalStore.