Порталы — корневой 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.body host-страницы вышел бы за пределы стилевого скоупа.
  • Themed-контейнер — стек порталов нужно держать внутри элемента с темой sn-* (см. Оформление), иначе всплывающий слой потеряет цветовую схему/плотность родителя.
  • Scoped stacking-контекст — все порталы поднимаются в один контейнер ради предсказуемого z-index и scoped CSS.
  • Тесты — портал нужно монтировать в фиксированный узел iframe Storybook/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-версиях одного домена — зафиксируйте единый мажор пакета.

Смотри также