SSR

  • Интеграции
  • 7 мин
  • кода: 4

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

Ревизия 2026-10-20

На этой странице

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

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

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

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

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

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

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

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

APIНа сервере
useTheme()Читать можно, писать — только с granularityThemePlugin: mode и isDark отдают режим запроса или режим по умолчанию, а setMode/toggleMode без плагина бросают. Течёт именно мутация: модульное состояние общее на все запросы
initThemeEarly()Ничего не делает: его задача — поставить пару до монтирования, и зовут его в браузерной точке входа
useToast()Требует granularityToastPlugin; без него бросает с объяснением
useDialogService()Сам композабл безопасен; методы, открывающие окно (confirm, alert, prompt, open), на сервере бросают: хост монтируется в 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('Delete the item?', { title: 'Delete?' })

Режим без мигания

Режим живёт парой data-theme="slate-dark" на <html>, и поставить её надо до первой отрисовки. Пары руками не пишутся: их знает сборка и отдаёт манифестом virtual:granum/themes — тем же, по которому собран CSS.

Без сервера — скрипт в <head>. Его вставляет плагин granum первым в <head> HTML-входа приложения:

vite.config.ts
granum(granumConfig, { themeScript: { storageKey: 'gr-theme' } })

Скрипт не трогает пару, уже пришедшую с сервера; иначе берёт режим из cookie (если её имя задано), из localStorage, по системной схеме и, наконец, режим по умолчанию. Ключ gr-theme обязателен: свой ключ по умолчанию у granum — granum-theme, а useTheme() хранит выбор под gr-theme, и при расхождении выбор не переживёт перезагрузку. Без плагина ту же строку отдаёт createThemeScript({ manifest, storageKey: 'gr-theme' }) из @feugene/granum/runtime; в Astro скрипт ставит интеграция @feugene/astro-granularity.

С сервером — пара в HTML. Если выбор хранится в cookie, сервер читает её из запроса и сразу отдаёт <html data-theme="slate-dark">, а приложение рендерит в той же теме и том же режиме:

entry-server.ts
import { GR_THEME_STORAGE_KEY } from '@feugene/granularity'
import { themeFromCookie, themeRootAttributes, themeSheetLink } from '@feugene/granum/runtime'
import manifest from 'virtual:granum/themes'

const selection = themeFromCookie(manifest, request.headers.cookie, GR_THEME_STORAGE_KEY)
const theme = selection && { theme: selection.theme.id, mode: selection.mode.name }
const htmlAttrs = theme ? themeRootAttributes(manifest, theme) : {} // { 'data-theme': 'slate-dark' }
// лист дополнительной темы — в <head>: в общем CSS его нет
const sheet = theme && themeSheetLink(manifest, theme.theme) // у основной темы — undefined
app.ts
import { GR_THEME_STORAGE_KEY, granularityThemePlugin } from '@feugene/granularity'
import { createCookieStorage } from '@feugene/granum/runtime'
import manifest from 'virtual:granum/themes'

app.use(granularityThemePlugin, {
  manifest,
  storage: createCookieStorage({ name: GR_THEME_STORAGE_KEY }),
  initial: theme, // на клиенте не передаётся: пару с сервера контроллер узнаёт сам
})

Неизвестный выбор и отсутствие cookie дают undefined: решать за пользователя по системной схеме сервер не может, и пару тогда ставит скрипт. Тему в initial передают вместе с режимом: по ней компоненты берут пропсы по умолчанию, и без неё серверный HTML разошёлся бы с гидрацией. Пару режима по умолчанию «на всякий случай» не пишите — скрипт оставит её как серверную, и сохранённый выбор не применится. Серверу, импортирующему @feugene/granum/runtime, granum нужен в dependencies, а не в devDependencies.

Оба пути сходятся на клиенте: useTheme() узнаёт пару, уже стоящую на корне, и не перезаписывает её. color-scheme объявляет CSS режима, ставить его руками не нужно.

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.

Поведение телепортов, модальных слоёв, useId(), тостов и режима на этой странице проверено прогоном SSR-стенда библиотеки с гидрацией; поведение остальных композаблов и директив — по их серверным гардам в коде. Nuxt и потоковый рендер стенд не покрывает.