# Тёплый ре-маунт: возврат на вкладку без «перезагрузки» > Статус: реализовано (dev, DEV-B703-01…03). Код: `src/houseplan-card.ts`, > модульная памятка `warmBoot`. Смоки: `demo/smoke_warm_remount.mjs`, > `demo/smoke_warm_dialogs.mjs`, `demo/smoke_warm_owners.mjs` (владение слотами) > и `demo/smoke_nav_persist.mjs` (постоянно хранится только пространство). ## 1. Что вообще происходит Lovelace **пересоздаёт элемент карточки** — выбрасывает старый DOM-элемент и создаёт новый — когда websocket переподключается после долго свёрнутой вкладки, при перестройке дашборда и при переключении видов. Страница при этом никуда не девается, но экземпляр карточки умирает, и вместе с ним умирает **всё, что жило в полях экземпляра**. Для владельца это выглядит как «карточка перезагрузилась»: * мигал прелоадер (закрыто DEV-B703-01); * «чуть-чуть дёргается масштаб» (DEV-B703-03, §2); * **исчезают открытые диалоговые окна** (DEV-B703-03, §3) — это и есть прямое доказательство пересоздания: состояние диалога больше нигде не хранится. Лекарство одно и то же: **модульная памятка** `warmBoot` — обычный `Map`, который живёт в загруженном JS-модуле, а не в экземпляре. Ключ: ``` `${window.innerWidth}x${window.innerHeight}|${location.pathname}|${JSON.stringify(config)}` ``` Смена размера окна → промах → полный защитный бут (единственный случай, когда хром HA может реально пересобраться заново). `location.pathname` — это дашборд И путь вида: две вкладки Lovelace никогда не делят ключ, поэтому карточка, открытая на другом виде, стартует холодной, а не с чужим вьюпортом. Хеш в ключ НЕ входит: `#space=` — наш собственный диплинк, а не другое размещение. Памятка не является постоянным хранилищем режима. С issue #93 уход на другой маршрут Home Assistant завершает редакторскую сессию: перед отсоединением слот запечатывается безопасным состоянием View без диалога и editor fingerprint. При возврате сохраняется пространство, но не редактор. Полный viewport и черновик переносятся только при техническом ре-маунте **на том же маршруте**. Одинаковый конфиг дважды на одном виде ключ по-прежнему не различает — этим и занимается §1.1. ### 1.1. Одна запись — одно РАЗМЕЩЕНИЕ карточки (AUD-159B1-01) Пока в памятке лежали два числа (высота шапки и высота сцены), «две одинаковые карточки неразличимы» было косметикой: высоты у них всё равно совпадают. Как только в записи появились вьюпорт и диалог, ограничение стало функциональным — аудит v1.59.0-beta.1 воспроизвёл это так: 1. A: вид, зум 2.15, открытый диалог пространства с черновиком; 2. B (тот же конфиг): «Устройства», зум 3.35 — последний писатель общей записи; 3. Lovelace создаёт замену A′ **до** того, как отсоединит A; 4. A′ усыновляет вьюпорт B, а черновик A потом отбраковывается проверкой «тот же режим» — пользователь видит чужой экран и теряет несохранённое. Поэтому значение ключа теперь **список слотов**, по одному на каждое размещение карточки. В слоте, кроме геометрии/вьюпорта/диалога, лежат: * `owner` — поколение живого экземпляра, сидящего в слоте; * `place` — **WeakRef** на родительский элемент, в который карточка была вставлена (слабая ссылка: памятка не имеет права держать DOM живым); * `idx` — позиция карточки среди детей этого родителя; * `live` — сидит ли в слоте кто-то живой. Слот занимается не в `setConfig` (Lovelace зовёт его **до** вставки элемента, размещения ещё не существует), а в `connectedCallback` — всё ещё до первого рендера, поэтому вуаль не мигает. Кандидаты ранжируются: | Оценка | Что это | Усыновляем | |---|---|---| | 4 | тот же родитель и тот же индекс — буквально наш DOM-слот; именно так Lovelace подменяет карточку (предшественник может быть ещё жив) | да | | 0 | **другое** размещение, владелец которого жив — соседняя карточка | никогда | | 3 | тот же родитель, индекс сдвинулся, владелец ушёл | да | | 2 | надгробие, чьё размещение исчезло вместе с поддеревом — обычная перестройка Lovelace | да | Ничья (два одинаково правдоподобных кандидата) означает: усыновляется **только осевшая высота шапки** — она у одинаковых карточек и так одинаковая, — но не вьюпорт и не диалог. Это худший случай: карточка открывается без вуали, но со своим собственным видом. Диалог воскрешается **из своего слота**, а не «из записи по ключу», поэтому чужой черновик физически недостижим. ### 1.2. Черновик едет по цепочке (AUD-159B1-02) `_warmSnapshot()` не трогает `dlg`, пока у экземпляра поднят `_warmRevivePending` — «я ещё не забрал диалог предшественника». Раньше `disconnectedCallback()` сбрасывал этот флаг **до** снимка, и промежуточный экземпляр в цепочке A→B→C записывал `dlg: null` поверх чужого черновика: двойное пересоздание в одном такте теряло несохранённый ввод. Теперь снимок делается **первым**, и черновик просто едет дальше по цепочке, пока кто-нибудь не проживёт достаточно долго, чтобы его открыть. Регрессия: `demo/smoke_warm_owners.mjs`, секция B. ### 1.3. TTL освобождает payload, а не только запрещает воскрешение (AUD-159B1-03) `WARM_REVIVE_MS` (10 c) был правилом, которое проверялось только в момент воскрешения. Запись при этом продолжала держать объект диалога — а у диалога пространства это **целый файл плана в base64** (бэкенд разрешает 8 MiB). На настенном планшете, который не перезагружают неделями, это десятки мегабайт бесполезного удержания. Теперь отсоединение заводит защищённый таймер: через `WARM_REVIVE_MS` он обнуляет `dlg`, если у слота та же метка `freed` и тот же владелец (успел прийти преемник — таймер молчит). Высота и вьюпорт остаются: это байты, и именно они делают следующий маунт тёплым. Заодно опустевший слот удаляется из списка, если он там не последний — чтобы следующее усыновление снова было однозначным. TTL является также независимой от порядка событий защитой редакторской сессии. После истечения окна слот переводит сохранённый viewport в `view`, сбрасывает editor-only выделения, инструмент и fingerprint. Поэтому поздний `location-changed`, отсоединение старого дерева и возвращение на прежний маршрут не могут воскресить редактор. Каждый слот дополнительно хранит `location.pathname`; viewport принимается только на том же маршруте. ## 2. Рывок масштаба: фактическая причина Памятка хранила только `hdrH`/`stageH`. Этого мало, потому что: 1. **Пан (`_view`) не переживал экземпляр вообще.** Единственным, что восстанавливалось, был зум — из `localStorage` (`LS_ZOOM`, per space). Новый экземпляр приходил с `_view = null`, `updated()` → `_refitView()`, а затем `_loadFromServer()` звал `_restoreZoom()`, который **центрирует план** (`_applyView(z, центр vb)`). Вид, отъеханный в угол, возвращался в центр. Измерено смоком: до пересоздания `view.x=50, y=493`, после — `x=250, y=293` при zoom 2.2. 2. **Зум редактора не сохраняется намеренно** (`_saveZoom()` выходит при `_mode !== 'view'`: рабочий зум редактора — инструмент, а не «как я хочу смотреть»). Исторически `LS_NAV` сохранял и режим, поэтому ре-маунт внутри редактора возвращался в тот же редактор, но на зуме ПРОСМОТРА: измерено 3.0 → 1.0. Теперь `LS_NAV` хранит только пространство; тот же редактор и его viewport переносит лишь same-route warm memo. 3. `_viewModeSnap` (виджет «куда вернуться при выходе из редактора») умирал вместе с экземпляром — выход из редактора после ре-маунта прыгал второй раз. 4. `_showFar` («показать дальние объекты») меняет `_baseVb()`, то есть саму систему координат, против которой клампится вид. **Правило для технического ре-маунта на том же маршруте:** памятка хранит не «зум», а **весь вьюпорт**: пространство, режим, зум, сам прямоугольник `_view`, `_viewModeSnap`, `_showFar`, а также инструмент редактора (`_tool`, `_decorTool`), выделение (`_selId`, `_rszSel`, `_decorSel`) и локальный переключатель «показать скрытые» (`_showHidden`). Восстановление даёт **бит-в-бит тот же прямоугольник**, а не «тот же зум». `_loadFromServer()` в этом случае **не зовёт** `_restoreZoom()` — центрирующее восстановление осталось только для настоящей навигации (хеш/`LS_NAV` привели на другое пространство). Явная команда пользователя всегда сильнее ещё не применённой памятки. Пока backend уточняет `can_write`, старый DOM редактора может оставаться видимым, а новый экземпляр уже fail-closed находится в Просмотре и держит режим только в `_pendingNavMode`. Любой вызов `_setMode()`, включая повторный `view` от видимого крестика, сначала отменяет этот pending: поздний ответ сервера не имеет права снова открыть уже закрытый редактор (#95). Диплинк `#space=` — явная навигация и по-прежнему сильнее памятки. Он выбирает пространство, но, как и обычный возврат на карточку, не включает редактор. Памятка обновляется **на каждом `updated()`** (метод `_warmSnapshot()`), то есть всегда отражает последний ОТРИСОВАННЫЙ кадр. Родиться запись может только из осевшей геометрии (`_bootSettled`), поэтому холодный бут по-прежнему платит полный защитный цикл. ### 2.1. Возврат после долгого сна вкладки Тёплый viewport решает пересоздание экземпляра, но после долгого background браузер, HA chrome и websocket могут проснуться в разных кадрах. В этот момент `ResizeObserver` способен выдать нулевой или промежуточный размер сцены. Такой размер больше никогда не меняет `_view`. Порог 15 секунд теперь только отмечает возможный browser freeze. Быстрый возврат — строгий no-op: не меняются token, DOM, viewport, day/night-фон и hover. После долгого сна общий `VisualContinuityController` удерживает на экране последний полноценный кадр, параллельно перепроверяет config/layout и принимает только подтверждённый положительный размер сцены. Класса `hpresume` и скрытия `.zoomwrap` больше нет ни в Просмотре, ни в редакторах, ни в киоске. Памятка `warmBoot` не стала вторым lifecycle-механизмом: она по-прежнему принадлежит placement и теперь дополнительно переносит fingerprint последнего полного кадра и компактный roster устройств. Подписи и readiness загруженных подложек принадлежат общему cache соединения, поэтому новый экземпляр может нарисовать защищённую подложку в первом кадре. Промежуточный `0×0` размер, неполный candidate и новая подписанная ссылка до decode в памятку не попадают. Толстые стены дополнительно имеют критические `fill`, `stroke`, opacity и `fill-rule` как SVG presentation attributes. Поэтому даже промежуточный кадр нового DOM-экземпляра не может получить браузерную заливку `black` до того, как применятся стили компонента; CSS hooks при этом сохранены и по обычному каскаду остаются сильнее presentation attributes. ## 3. Диалоги переживают пересоздание В той же записи памятки лежит `dlg`: `{ kind, space, mode, data }`, где `data` — **живой объект-черновик**. Памятка — состояние модуля, её никто не сериализует, поэтому наполовину заполненный диалог устройства вместе с загруженными PDF переезжает бесплатно и без потерь. Восстановление (`_warmReviveDialog`) происходит **не в `setConfig`**, а на следующий такт после `connectedCallback`: Lovelace в момент `setConfig` может ещё держать старый элемент, а живому владельцу диалог красть нельзя. Условия воскрешения — все обязательны: 1. запись памятки есть и маршрут не менялся (технический тёплый ре-маунт, а не холодный старт или возврат с другой страницы HA); 2. `dlg` не пуст — то есть в **последнем отрисованном кадре** прошлого экземпляра диалог был открыт; 3. прошлый экземпляр **отсоединился** (`freed`), и с этого момента прошло не больше `WARM_REVIVE_MS` (10 c). Перестройка Lovelace укладывается в один такт; затянувшийся технический ре-маунт не должен возвращать диалог, о котором пользователь уже забыл; 4. **то же пространство и тот же режим** (`d.space`/`d.mode`); 5. **одноразово**: `dlg` съедается в момент попытки восстановления. Третий экземпляр диалог уже не увидит, зомби невозможен. Осознанное закрытие (Esc / Отмена / Сохранить) чистить памятку отдельно **не нужно**: следующий же `updated()` запишет `dlg: null`. Это сильнее ручной чистки — ни один путь закрытия нельзя забыть. Уход на другой маршрут HA сильнее TTL и всех условий выше: editor/dialog сбрасываются немедленно и при возврате не воскрешаются даже в пределах 10 с. ### 3.1. Что восстанавливается с черновиком `_spaceDialog` (настройки/создание пространства), `_markerDialog` (устройство), `_settingsDialog` (общие настройки), `_rulesDialog` (правила иконок), `_openingDialog` (проём), `_decorTextDialog` (надпись), `_decorShapeDialog` (полные свойства объекта подложки), `_backdropDialog` (числовая трансформация картинки), `_roomDialog` вместе со всей своей обвязкой (`_roomEditId`, `_roomFill`, источники температуры и влажности, масштабы подписей, `_areaSel`/`_nameSel`, `_pendingSplit`, `_path`). Информационные попапы — `_infoCard` (карточка устройства) и `_openingInfo` — восстанавливаются **по id**, а не по объекту: конфиг мог перезагрузиться под нами, и карточка, отрисованная из устаревшего объекта, была бы враньём. Если объекта с таким id больше нет — попап просто не открывается. ### 3.2. Что НЕ восстанавливается — и почему | Не воскрешаем | Причина | |---|---| | «**Выровнять всё по сетке**» (`_alignDialog`) | Модалка, всё содержимое которой — «нажмите OK, и я перепишу ваш план». Воскресить подтверждение рядом с человеком, который только что вернулся на вкладку, — прямой путь к слепому клику по разрушающей записи. Открывается одним нажатием из шестерёнки. | | Подтверждение **объединения комнат** (`_mergeDialog`) | Тот же класс: подтверждение необратимой правки геометрии. | | Подтверждение действия по тапу (`_tapConfirm`) | Держит замыкание `exec` на **мёртвый** экземпляр. | | Мастер импорта этажей (`_importDialog`) | `updated()` сам открывает его, пока конфиг пуст; воскрешение удвоило бы очередь. | | Любой диалог с `busy: true` | Запись/загрузка была в полёте. Новый экземпляр не знает, доехала ли она; показать «Сохранить» ещё раз — это приглашение записать дважды. Правду покажет перезагрузка конфига. | | Киоск | У киоска памятки нет вообще (`100dvh`, ничего не оседает, редактирование недоступно). | Правило одной строкой: **воскрешаем черновик, не воскрешаем решение.** ## 4. Что осознанно отложено Ре-маунт по-прежнему теряет мелкое промежуточное состояние жестов, у которого нет осмысленного «продолжения» после смены экземпляра: незавершённый контур рисования вне диалога комнаты (`_path`/`_cursorPt`), общий сессионный стек геометрических команд (`_geometryHistory`), выбор разреза (`_splitSel`, `_mergeSel`), черновик фигуры декора (`_decorDraft`), незавершённое перетаскивание подложки (`_bdDrag`), тост (`_toast`). Все они живут внутри одного жеста; пересоздание элемента жест и так прерывает.