Files
houseplan-card/docs/WARM-REMOUNT.md
T
Claude 4bc3e9baa6 docs(hygiene): ARCHITECTURE.md — карта, а не хроника (#680)
Волна 3 эпика #674. ARCHITECTURE.md 2330 → 653 строки: система координат
прототипа 1489×1053, разделы Hidden Isometric Stage 2/4 (текущее — ISOMETRIC.md,
история — ADR 122/570), хроники «Additions v1.28–v1.41» и «Audit follow-ups»
сняты; подсистемы — короткое описание со ссылкой на канонический документ.
Устаревшее исправлено по коду: дерево Layout (store.py, frontend_registration,
весь список модулей), хранилища (config, layout, virtual_lights, trails),
ленивые локали de и fr, таблица WS API (31 + 3 команды, #256-проекция,
space/delete, files/cleanup без keep, контент через /api/houseplan/content),
DevItem без несуществующих полей, отказ help/feedback по support_api.

Всё ещё верное и не записанное в другом месте перенесено в канонические
документы: DEVICE-PRESENTATION (заметки реализации, Action authority, черновик
диалога), RADAR (карта реализации), VACUUM (владение кодом), CANVAS (icon_size,
--hp-cell-visual-scale, барьер записи координат), FILTERING (#44, каталог),
DECOR-EDITOR §7 (инварианты бэкенда ассетов), WALL-THICKNESS (§1 идентичность
и нулевые стены, §2 кэши, §4 hover/туннели/острова, §6 удаление комнаты,
§9 failed-core, §11 завершение цепочки и комната по грани), ISOMETRIC
(isoPlaneMatrix, iso-overlays, створки, служебные атрибуты), LIGHT (Glow над
заливкой #55, формула и screen-смешение), CONFIG-COMPATIBILITY (манифест схемы
#33, Masonry-слоты #561, квадратный холст v1.48, устаревшие URL контента,
layout/set #356), SCOPE (таблица владельцев при сборке файлов), WARM-REMOUNT
§5 (холодная загрузка и визуальная непрерывность), UX-MODES (порядок
стартового пространства), PDF-EXPORT (граница реализации), FURNITURE (adopt,
BOOT_MAX_MS). Раздел TESTING «Backend quality gates (#42)» и строка
DEVELOPMENT о bundle-freshness — из того же разбора, в предыдущем коммите.

Issue: #680
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-27 22:33:04 +03:00

300 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Тёплый ре-маунт: возврат на вкладку без «перезагрузки»
> Код: `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=<id>` — явная навигация и по-прежнему сильнее памятки. Он
выбирает пространство, но, как и обычный возврат на карточку, не включает
редактор.
Памятка обновляется **на каждом `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`, `CONFIG-COMPATIBILITY.md` › Optimize plans) | Модалка, всё содержимое которой — «нажмите OK, и я перепишу ваш план». Воскресить подтверждение рядом с человеком, который только что вернулся на вкладку, — прямой путь к слепому клику по разрушающей записи. Открывается одним нажатием из общих настроек. |
| Подтверждение **объединения комнат** (`_mergeDialog`) | Тот же класс: подтверждение необратимой правки геометрии. |
| Подтверждение действия по тапу (`_tapConfirm`) | Держит замыкание `exec` на **мёртвый** экземпляр. |
| Мастер импорта этажей (`_importDialog`) | `updated()` сам открывает его, пока конфиг пуст; воскрешение удвоило бы очередь. |
| Любой диалог с `busy: true` | Запись/загрузка была в полёте. Новый экземпляр не знает, доехала ли она; показать «Сохранить» ещё раз — это приглашение записать дважды. Правду покажет перезагрузка конфига. |
| Киоск | У киоска памятки нет вообще (`100dvh`, ничего не оседает, редактирование недоступно). |
Правило одной строкой: **воскрешаем черновик, не воскрешаем решение.**
## 4. Что осознанно отложено
Ре-маунт по-прежнему теряет мелкое промежуточное состояние жестов, у которого
нет осмысленного «продолжения» после смены экземпляра: незавершённый контур
рисования вне диалога комнаты (`_path`/`_cursorPt`), общий сессионный стек
геометрических команд (`_geometryHistory`), выбор разреза (`_splitSel`, `_mergeSel`), черновик фигуры декора
(`_decorDraft`), незавершённое перетаскивание подложки (`_bdDrag`), тост
(`_toast`). Все они живут внутри одного жеста; пересоздание элемента жест и так
прерывает.
## 5. Холодная загрузка и визуальная непрерывность (#73, #131)
`src/visual-continuity.ts` владеет одним машинным состоянием с токенами, общим
для полной и статической карточек. Готовый кадр остаётся на экране через
возобновление, переподключение, структурную перепроверку и изменения размера с
положительной площадью; наблюдения `0×0` viewport не меняют. Config и layout
несут независимые идентичности «ревизия + отпечаток содержимого»: эхо, где
сменилась только ревизия, сохраняет авторитетные объекты и кэши геометрии, а
изменённое содержимое не спрячется за равной ревизией. Кандидат готов только
после того, как Lit осел, обязательные подписанные ресурсы загрузились и для его
токена прошли две возможности кадра анимации; `data-continuity-state`,
`data-continuity-token`, `data-frame-fingerprint` и условный
`data-recovery-reason` показывают это без entity id и URL. Рантайм подписанных
ресурсов привязан к `hass.connection`, ограничен и общий для размещений, поэтому
тёплый ре-маунт может синхронно переиспользовать загруженную подложку;
обновление — stale-while-decode. Только когда сохранить нечего — ни готового, ни
устаревшего кадра, — через 150 мс появляется локализованный непрозрачный оверлей
восстановления; он никогда не забирает начальный фокус и, пока виден, делает
сцену `inert`.
Обязательная загрузка заканчивается, когда `houseplan-card` принял config и
layout, построил модель, выбрал одно точное пространство, закэшировал снимок и
восстановил viewport. Подписки на config, след и layout стартуют после этого
вместе, как независимые необязательные обогащения: отказ одного канала не
блокирует остальные, не стирает снимок и не планирует повтор полной загрузки;
недостающие каналы повторяются при следующей загрузке или переподключении.
`src/initial-load.ts` — единственный источник решения о пространстве
закэшированного снимка, живого снимка и его кандидата защищённой подложки.
`floor` в конфиге карточки абсолютен (`resolveFixedFloor()`: строка — точный id,
конечное неотрицательное целое — индекс с нуля по серверу); неверные значения
отказывают закрыто, числовой индекс ждёт свежую серверную модель до первого
пространственного кадра, а закреплённый экземпляр никогда не читает и не пишет
`houseplan_card_nav_v1` — через этот гард проходит каждый переход `_space`. Без
`floor` холодная загрузка перебирает валидные id в порядке: хеш URL, сохранённая
навигация, `default_floor`, первое живое пространство; после того как начальный
хеш использован, валидный выбор в том же маршруте сохраняется; план без
пространств оставляет авторитет `null`.