# SSR

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

- Page: https://granularity.tech/ru/docs/ssr
- Last reviewed: 2026-10-20

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

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

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

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

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

:::callout{type="info"}
**Исключение — содержимое модальных слоёв.** `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-входа приложения:

```ts title="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">`, а приложение рендерит в
той же теме и том же режиме:

```ts title="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
```

```ts title="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 на серверный рендер не влияет: он статичен. Критично только одно — режим
должен примениться до первой отрисовки, иначе пользователь с тёмным режимом
увидит вспышку светлого.

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

:::steps
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 и
потоковый рендер стенд не покрывает.
