SSR

Пакет безопасен для серверного рендера целиком. Оговорки касаются не безопасности, а того, что именно приходит с сервера.

Nuxt, vite-ssr или собственный @vue/server-renderer — короткий ответ один: весь пакет рендерится на сервере и гидрируется без расхождений. Дальше — оговорки о том, что именно приезжает с сервера и где нужен плагин.

Телепорт включается после гидрации

Панели, всплывашки и оверлеи живут в body, но попадают туда не сразу. Контракт один на все компоненты: телепорт выключен на сервере и на первом клиентском рендере, а включается в onMounted. Отключённый телепорт означает «рендерить на месте», поэтому серверный HTML и первый клиентский рендер совпадают.

Что из этого следует для приложения:

  • панели приходят с сервера — внутри разметки своего компонента и скрытыми, поэтому вспышки раскрытого списка нет;
  • якоря телепортов всё равно надо вставлять в разметку, иначе Vue не найдёт точку привязки;
  • обёртки client-only не нужны ни одному компоненту пакета.

Исключение — содержимое модалок. GrModal и всё, что на нём построено — GrDialog, GrConfirmDialog, GrPromptDialog, GrCommandPalette, GrImageViewer, GrDrawer, — не отдаёт содержимое на сервер вообще, даже будучи открытым. Практический вывод: в первый экран и в поисковую выдачу оно не попадает, а расхождения гидрации внутри него в принципе невозможны.

Пишете свой компонент с телепортом — берите usePortalTarget() и его enabled, а не typeof window !== 'undefined'. Проверка окружения выглядит достаточной, но именно она и создаёт расхождение: на сервере и на первом клиентском рендере она даёт разный результат.

Композаблы: что требует плагина

APIНа сервере
useTheme()Читать можно, писать — только с granularityThemePlugin. Течёт именно мутация: модульное состояние общее на все запросы
useToast()Требует granularityToastPlugin; без него бросает с объяснением
useDialogService()Сам композабл безопасен, любой его метод на сервере бросает: он монтирует хост в document.body
useAnnouncer()Безопасен, объявление — no-op: живой регион это узел документа, а не переносимое состояние
useGrConfig(), useGrFormFieldContext()Чистые, DOM не трогают
useOverlayLayer()Безопасен: на сервере слой в стек не заводится вовсе
vClickOutside, vHotkey, vLoadingРаботают в хуках монтирования, на сервере не вызываются

Общее правило у запретов одно: модульное состояние на сервере — это состояние, общее для всех запросов, и выбор одного пользователя уехал бы в ответ другому. Плагин даёт каждому приложению своё.

ClientOnly — кому он нужен

Компонентам пакета — ни одному, и оборачивать их вредно: пропадёт серверная разметка, а с ней и смысл SSR. Обёртка нужна вашему коду, который читает среду прямо в шаблоне: ширину окна, navigator, localStorage, время в локальной зоне.

Императивные API она не закрывает вовсе — они не рендерятся, прятать надо вызов, а не разметку:

ts
if (typeof window !== 'undefined')
  await useDialogService().confirm({ title: 'Delete?' })

Тема без мигания

Сервер не знает, какую тему выбрал пользователь. Решение — инлайновый скрипт в <head> до первого рендера:

html
<script>
  try {
    var t = localStorage.getItem('gr-theme')
      || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
    document.documentElement.dataset.theme = t
  }
  catch {}
</script>

Ключ gr-theme и атрибут data-theme — тот же контракт, что использует useTheme(). Если тему выбирает сервер — из куки или профиля, — ставьте granularityThemePlugin: простому SPA он не нужен, там модульного синглтона достаточно.

CSS на серверный рендер не влияет: он статичен. Критично только одно — тема должна примениться до первой отрисовки, иначе пользователь с тёмной темой увидит вспышку светлой.

Правила для своего компонента

  1. DOM — только в onMounted/onBeforeUnmount и в обработчиках. Ни одного обращения в теле setup и на уровне модуля.
  2. Нужен document или window вне хука — гард typeof window === 'undefined', а не try/catch.
  3. Телепортируете — берите enabled из usePortalTarget(), не проверку окружения.
  4. Браузерные API, которых может не быть и в браузере (ResizeObserver, matchMedia), проверяйте на существование.
  5. DOM-идентификаторы — только useId(). Ни счётчик экземпляра (на сервере он растёт между запросами, на клиенте стартует с нуля), ни случайное число: расхождение придёт молча — через id, aria-controls, aria-labelledby.
  6. Значение, зависящее от среды, в первом рендере не использовать: ref с серверным значением и уточнение в onMounted.

Все утверждения этой страницы — результат прогона SSR-стенда библиотеки с гидрацией, а не чтения исходников.

Последняя ревизия: 2026-09-01