Порталы — корневой DOM-узел
Tooltip, Popover, Dropdown, Modal, Drawer рендерят свой слой через createPortal — не в месте вызова, а в отдельный DOM-узел. Где этот узел, дизайн-система решает двумя слоями:
- Узел — в React-контексте. Целевой DOM-узел живёт в контексте
@cloud-ru/ds-portal-context, а не в пропах каждого портального компонента. Источник — одинPortalContextProviderлибо глобальный дефолт. - Дефолт — глобальный singleton-ref. Без провайдера контекст отдаёт
getGlobalPortalRoot()—Symbol.for-синглтон корня порталов, общий для всех React-корней процесса. Поэтому потребитель безPortalContextProviderмонтирует порталы в узел, который оболочка контейнера объявила один раз. Контракт значения (одинRefObject) не растёт с числом компонентов, поэтому он устойчив к версиям в микрофронтах (MFE) и безопасен для SSR.
Поверх этого приложение может переопределить узел в поддереве (<PortalContextProvider root={ref} />) — например, увести порталы внутрь shadow DOM, iframe или themed-контейнера.
Значение контекста — мутабельный
RefObject, не реактивный стор. Порталы читают.currentлениво в момент открытия, поэтому смена узла не вызывает перерендер и провайдеру не нуженsetAppearance/setLang-эквивалент: оболочка контейнера присваивает.current.
Кратко
import { getGlobalPortalRoot, PortalContextProvider, usePortalContext } from '@cloud-ru/ds-portal-context';
// Без провайдера: порталы DS уже работают, монтируются в document.body по умолчанию.
// Оболочка контейнера, один раз при инициализации — увести все порталы в themed-корень:
getGlobalPortalRoot().current = document.querySelector('.sn-dark') ?? document.body;
// Поддерево с нестандартным таргетом (shadow DOM, iframe, scoped stacking-контекст):
<PortalContextProvider root={shadowRootRef}>{section}</PortalContextProvider>;
// Внутри портального компонента — читать узел лениво при открытии:
function FloatingLayer({ children }: { children: ReactNode }) {
const root = usePortalContext();
if (!root.current) return null; // SSR или узел ещё не задан
return createPortal(children, root.current);
}
В обычном SPA провайдер не нужен — компоненты по умолчанию используют document.body.
Зачем нужен контекст
document.body подходит большинству приложений, но не всем. Контекст нужен, когда корень <body> не годится:
- shadow DOM / iframe — DS встроена в изолированный корень, и портал в
document.bodyhost-страницы вышел бы за пределы стилевого скоупа. - Themed-контейнер — стек порталов нужно держать внутри элемента с темой
sn-*(см. Оформление), иначе всплывающий слой потеряет цветовую схему/плотность родителя. - Scoped stacking-контекст — все порталы поднимаются в один контейнер ради предсказуемого
z-indexи scoped CSS. - Тесты — портал нужно монтировать в фиксированный узел
iframeStorybook/Playwright.
Контекст: значение — ref на DOM-узел
Значение контекста — RefObject<HTMLElement | null>. Потребитель не получает узел напрямую, а читает .current в момент, когда реально открывает портал. Это и делает модель ленивой: узел может появиться (или смениться) уже после монтирования компонента — важно лишь, чтобы он был выставлен к первому открытию слоя.
import { usePortalContext } from '@cloud-ru/ds-portal-context';
import { createPortal } from 'react-dom';
function Tooltip({ children }: { children: ReactNode }) {
const root = usePortalContext();
// .current читается здесь, а не на монтировании — узел берётся «как есть» при открытии.
if (!root.current) return null;
return createPortal(children, root.current);
}
Провайдер: переопределить узел в поддереве
PortalContextProvider опционален и задаёт узел для своего поддерева:
type PortalContextProviderProps = {
root?: RefObject<HTMLElement | null>; // целевой узел; по умолчанию — глобальный singleton-ref
children: ReactNode;
};
- С
root— порталы поддерева монтируются в переданный ref. Используется для shadow DOM, iframe, themed-контейнера, фиксированного узла в тестах. - Без
root— провайдер подставляетgetGlobalPortalRoot(), то есть ведёт себя как дефолт. Оборачивать ради этого не нужно.
function ScopedSection({ children }: { children: ReactNode }) {
const rootRef = useRef<HTMLDivElement>(null);
return (
<PortalContextProvider root={rootRef}>
{children}
{/* порталы этого поддерева сядут сюда, а не в document.body */}
<div ref={rootRef} />
</PortalContextProvider>
);
}
Глобальный singleton-ref: getGlobalPortalRoot
Дефолт контекста — getGlobalPortalRoot(): один RefObject корня порталов, хранящийся в globalThis через Symbol.for. За счёт этого он общий для всех React-корней процесса (микрофронты single-spa, островки Astro) и переживает несколько копий пакета @cloud-ru/ds-portal-context в бандле.
Оболочка контейнера задаёт целевой узел один раз при инициализации, и все микрофронты без собственного PortalContextProvider монтируют порталы туда:
import { getGlobalPortalRoot } from '@cloud-ru/ds-portal-context';
// в оболочке контейнера, до первого открытия любого портала:
getGlobalPortalRoot().current = document.body; // или выделенный themed-root
Дефолтное значение ref — document.body (в браузере) либо null (SSR). Менять глобальный узел не обязательно: если он устраивает, ни оболочке, ни микрофронту делать ничего не нужно.
Рецепт повторяет getGlobalThemeStore / getGlobalLocaleStore (см. Оформление, Локализация), но с одним отличием: здесь это мутабельный ref, а не реактивный стор. Порталы читают .current императивно при открытии, поэтому подписки/перерендера на смену узла нет.
Реальный кейс: один корень порталов поверх интерфейса приложения
Так устроен сам портал документации DS. document.body как корень работает, но если у приложения есть собственный sticky-хедер или сайдбар с z-index, возникает конфликт: оверлеи DS (Modal, Drawer, Popover) держат z-index: 0 и рассчитывают перекрывать фон по порядку в DOM — будучи порталом в конце body. Хедер с z-index: 50 оказывается выше такого портала и «вылезает» поверх модалки.
Решение — выделенный контейнер последним ребёнком body с z-index выше всех элементов интерфейса; туда направляется глобальный корень порталов. Три шага:
<!-- 1. Пустой контейнер-корень последним ребёнком <body> -->
<body>
<!-- хедер, сайдбар, контент приложения -->
<div id="portal-root"></div>
</body>
/* 2. z-index выше любого элемента интерфейса приложения */
#portal-root {
position: relative;
z-index: 9999;
}
// 3. Один раз при инициализации оболочки, до первого открытия портала:
import { getGlobalPortalRoot } from '@cloud-ru/ds-portal-context';
getGlobalPortalRoot().current = document.getElementById('portal-root');
После этого ни одному экрану не нужен свой PortalContextProvider — все оверлеи DS монтируются в #portal-root и перекрывают хедер и сайдбар. Два важных следствия модели:
- Порядок внутри контейнера сохраняется.
z-index: 9999стоит на самом контейнере, а оверлеи внутри по-прежнемуz-index: 0и стекаются по DOM-порядку. Поэтому тултип, открытый из модалки (он добавляется в#portal-rootпозже), остаётся над ней — как и задумано в DS. - Скролл страницы — на
body, не на<html>. Оверлеи блокируют скролл черезreact-remove-scroll(body { overflow: hidden }). Если страница скроллится на<html>, эта блокировка превращаетbodyв новый scroll-контейнер, иposition: sticky-хедер/сайдбар «уезжают». Когда скролл живёт наbody(html { overflow: hidden; height: 100% },body { overflow: auto; height: 100% }), блокировка замораживает позицию на месте и sticky-раскладка остаётся целой.
9999 — пример: достаточно любого значения выше z-index элементов интерфейса. Контейнер наследует тему sn-*, если она объявлена на <html> (см. Оформление).
MFE и SSR
- MFE. Объект контекста —
Symbol.for-синглтон (providerKey('portal-context', 1)): потребитель в микрофронте читает ближайший провайдер независимо от версии пакета. ГлобальныйgetGlobalPortalRoot()(тожеSymbol.for, ключ@cloud-ru/ds:global-portal-root:v1) даёт один корень порталов на все React-корни и служит дефолтом контекста — поэтому MFE безPortalContextProviderвсё равно монтирует порталы в общий узел. Контракт значения (RefObject) не растёт с числом компонентов — оба ключа остаютсяv1и двигаются только при несовместимом изменении формы значения. - SSR. На сервере дефолтный ref —
{ current: null }(мутабельный глобал с реальнымdocument.bodyна сервере запрещён — утёк бы между запросами). Порталам нужен DOM, поэтому приroot.current === nullпотребитель ничего не рендерит до гидрации, а реальный узел появляется на клиенте.
Частые проблемы
- Порталы монтируются в
document.body, а не в нужный контейнер. НетPortalContextProvider root={ref}над компонентом, и глобальный узел не переопределён. Либо оберните поддерево провайдером сroot, либо вызовитеgetGlobalPortalRoot().current = elв оболочке контейнера. - Узел задан, но первый портал всё равно смонтировался в
document.body.getGlobalPortalRoot().current = …выполнен после того, как слой уже открылся. Присваивайте узел один раз при инициализации контейнера, до первого открытия портала. root.current===null, портал не рендерится. Ref ещё не привязан к DOM (элемент-цель рендерится ниже по дереву или это SSR). Это ожидаемо: потребитель возвращаетnull, пока узла нет. Убедитесь, что элемент сrefсмонтирован.- Несколько копий
@cloud-ru/ds-portal-context. Корень порталов это ломать не должно: контекст и глобальный ref —Symbol.for-синглтоны, общие между копиями. При смешанных contract-версиях одного домена — зафиксируйте единый мажор пакета.
Смотри также
- PortalContext — справочник API пакета и живой пример.
- Оформление — тема, бренд, плотность — тот же
Symbol.for-рецепт для классовsn-*; themed-корень для порталов. - Локализация — строки в пакетах — та же двухслойная модель для строк.