18 KiB
Тёплый ре-маунт: возврат на вкладку без «перезагрузки»
Статус: реализовано (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 воспроизвёл это так:
- A: вид, зум 2.15, открытый диалог пространства с черновиком;
- B (тот же конфиг): «Устройства», зум 3.35 — последний писатель общей записи;
- Lovelace создаёт замену A′ до того, как отсоединит A;
- 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. Этого мало, потому что:
- Пан (
_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. - Зум редактора не сохраняется намеренно (
_saveZoom()выходит при_mode !== 'view': рабочий зум редактора — инструмент, а не «как я хочу смотреть»), а режим редактора сохраняется (LS_NAV). Значит ре-маунт внутри редактора возвращался в тот же редактор, но на зуме ПРОСМОТРА: измерено 3.0 → 1.0. _viewModeSnap(виджет «куда вернуться при выходе из редактора») умирал вместе с экземпляром — выход из редактора после ре-маунта прыгал второй раз._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 может
ещё держать старый элемент, а живому владельцу диалог красть нельзя.
Условия воскрешения — все обязательны:
- запись памятки есть (тёплый ре-маунт, а не холодный старт);
dlgне пуст — то есть в последнем отрисованном кадре прошлого экземпляра диалог был открыт;- прошлый экземпляр отсоединился (
freed), и с этого момента прошло не большеWARM_REVIVE_MS(10 c). Перестройка Lovelace укладывается в один такт; ушедший на другой дашборд и вернувшийся через полчаса пользователь не должен встречать диалог, о котором он давно забыл; - то же пространство и тот же режим (
d.space/d.mode); - одноразово:
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). Все они живут внутри одного жеста; пересоздание элемента жест и так
прерывает.