Files
houseplan-card/docs/WARM-REMOUNT.md
T

23 KiB
Raw Blame History

Тёплый ре-маунт: возврат на вкладку без «перезагрузки»

Статус: реализовано (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 и тот же владелец (успел прийти преемник — таймер молчит). Высота и вьюпорт остаются: это байты, и именно они делают следующий маунт тёплым. Заодно опустевший слот удаляется из списка, если он там не последний — чтобы следующее усыновление снова было однозначным.

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) Модалка, всё содержимое которой — «нажмите OK, и я перепишу ваш план». Воскресить подтверждение рядом с человеком, который только что вернулся на вкладку, — прямой путь к слепому клику по разрушающей записи. Открывается одним нажатием из шестерёнки.
Подтверждение объединения комнат (_mergeDialog) Тот же класс: подтверждение необратимой правки геометрии.
Подтверждение действия по тапу (_tapConfirm) Держит замыкание exec на мёртвый экземпляр.
Мастер импорта этажей (_importDialog) updated() сам открывает его, пока конфиг пуст; воскрешение удвоило бы очередь.
Любой диалог с busy: true Запись/загрузка была в полёте. Новый экземпляр не знает, доехала ли она; показать «Сохранить» ещё раз — это приглашение записать дважды. Правду покажет перезагрузка конфига.
Киоск У киоска памятки нет вообще (100dvh, ничего не оседает, редактирование недоступно).

Правило одной строкой: воскрешаем черновик, не воскрешаем решение.

4. Что осознанно отложено

Ре-маунт по-прежнему теряет мелкое промежуточное состояние жестов, у которого нет осмысленного «продолжения» после смены экземпляра: незавершённый контур рисования вне диалога комнаты (_path/_cursorPt), общий сессионный стек геометрических команд (_geometryHistory), выбор разреза (_splitSel, _mergeSel), черновик фигуры декора (_decorDraft), незавершённое перетаскивание подложки (_bdDrag), тост (_toast). Все они живут внутри одного жеста; пересоздание элемента жест и так прерывает.