mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 04:38:55 +00:00
203 lines
18 KiB
Markdown
203 lines
18 KiB
Markdown
# Тёплый ре-маунт: возврат на вкладку без «перезагрузки»
|
||
|
||
> Статус: реализовано (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` (владение слотами).
|
||
|
||
## 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=` — наш собственный диплинк, а не другое размещение.
|
||
|
||
Одинаковый конфиг дважды на одном виде ключ по-прежнему не различает — этим и
|
||
занимается §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` и тот же
|
||
владелец (успел прийти преемник — таймер молчит). Высота и вьюпорт остаются:
|
||
это байты, и именно они делают следующий маунт тёплым. Заодно опустевший слот
|
||
удаляется из списка, если он там не последний — чтобы следующее усыновление
|
||
снова было однозначным.
|
||
|
||
## 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.
|
||
3. `_viewModeSnap` (виджет «куда вернуться при выходе из редактора») умирал
|
||
вместе с экземпляром — выход из редактора после ре-маунта прыгал второй раз.
|
||
4. `_showFar` («показать дальние объекты») меняет `_baseVb()`, то есть саму
|
||
систему координат, против которой клампится вид.
|
||
|
||
**Правило теперь:** памятка хранит не «зум», а **весь вьюпорт**:
|
||
пространство, режим, зум, сам прямоугольник `_view`, `_viewModeSnap`,
|
||
`_showFar`, а также инструмент редактора (`_tool`, `_decorTool`), выделение
|
||
(`_selId`, `_rszSel`, `_decorSel`) и локальный переключатель «показать
|
||
скрытые» (`_showHidden`). Восстановление даёт **бит-в-бит тот же прямоугольник**,
|
||
а не «тот же зум». `_loadFromServer()` в этом случае **не зовёт**
|
||
`_restoreZoom()` — центрирующее восстановление осталось только для настоящей
|
||
навигации (хеш/`LS_NAV` привели на другое пространство).
|
||
|
||
Диплинк `#space=<id>` — явная навигация и по-прежнему сильнее памятки.
|
||
|
||
Памятка обновляется **на каждом `updated()`** (метод `_warmSnapshot()`), то
|
||
есть всегда отражает последний ОТРИСОВАННЫЙ кадр. Родиться запись может только
|
||
из осевшей геометрии (`_bootSettled`), поэтому холодный бут по-прежнему платит
|
||
полный защитный цикл.
|
||
|
||
## 3. Диалоги переживают пересоздание
|
||
|
||
В той же записи памятки лежит `dlg`: `{ kind, space, mode, data }`, где
|
||
`data` — **живой объект-черновик**. Памятка — состояние модуля, её никто не
|
||
сериализует, поэтому наполовину заполненный диалог устройства вместе с
|
||
загруженными PDF переезжает бесплатно и без потерь.
|
||
|
||
Восстановление (`_warmReviveDialog`) происходит **не в `setConfig`**, а на
|
||
следующий такт после `connectedCallback`: Lovelace в момент `setConfig` может
|
||
ещё держать старый элемент, а живому владельцу диалог красть нельзя.
|
||
|
||
Условия воскрешения — все обязательны:
|
||
|
||
1. запись памятки есть (тёплый ре-маунт, а не холодный старт);
|
||
2. `dlg` не пуст — то есть в **последнем отрисованном кадре** прошлого
|
||
экземпляра диалог был открыт;
|
||
3. прошлый экземпляр **отсоединился** (`freed`), и с этого момента прошло не
|
||
больше `WARM_REVIVE_MS` (10 c). Перестройка Lovelace укладывается в один
|
||
такт; ушедший на другой дашборд и вернувшийся через полчаса пользователь не
|
||
должен встречать диалог, о котором он давно забыл;
|
||
4. **то же пространство и тот же режим** (`d.space`/`d.mode`);
|
||
5. **одноразово**: `dlg` съедается в момент попытки восстановления. Третий
|
||
экземпляр диалог уже не увидит, зомби невозможен.
|
||
|
||
Осознанное закрытие (Esc / Отмена / Сохранить) чистить памятку отдельно **не
|
||
нужно**: следующий же `updated()` запишет `dlg: null`. Это сильнее ручной
|
||
чистки — ни один путь закрытия нельзя забыть.
|
||
|
||
### 3.1. Что восстанавливается с черновиком
|
||
|
||
`_spaceDialog` (настройки/создание пространства), `_markerDialog` (устройство),
|
||
`_settingsDialog` (общие настройки), `_rulesDialog` (правила иконок),
|
||
`_openingDialog` (проём), `_decorTextDialog` (надпись), `_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`), стек отмены ресайза
|
||
(`_rszUndo`), выбор разреза (`_splitSel`, `_mergeSel`), черновик фигуры декора
|
||
(`_decorDraft`), незавершённое перетаскивание подложки (`_bdDrag`), тост
|
||
(`_toast`). Все они живут внутри одного жеста; пересоздание элемента жест и так
|
||
прерывает.
|