Files
houseplan-card/docs/WARM-REMOUNT.md
Claudeandclaude[bot] 5a258f3128 docs(hygiene): свести дубли документов подсистем и снять устаревшее (#679)
Волна 2 эпика #674 — у каждого правила один дом, остальные места ссылаются.

DECOR-EDITOR.md ← BACKDROP.md + LIVE-TEXT.md: один документ с нумерованными
разделами (§3 подложка, §5 текст с живыми значениями), на которые теперь
указывают комментарии кода вместо несуществовавших «BACKDROP §2/§3»;
исправлено утверждение, что space-card не рисует декор (он рисует подложку
и картинки декора, но не фигуры, мебель и текст). LIGHT.md ← матрица
настроек света (перевод, тест назван явно: test/devices.test.mjs «issues
84/88»). DEVICE-PRESENTATION.md ← правила «что показывает маркер» из
FILTERING.md (порядок cover → light sources → device role, шторы,
медиаплееры); «в одном pull request» → «в одном коммите». CANVAS.md: §9.5
«Оптимизировать планы» → CONFIG-COMPATIBILITY.md, overlay и планарные грани
Walls → WALL-THICKNESS.md §10–11, таблицы «было/стало» сняты. TESTING-DEMO.md
→ demo/stand/README.md: карта демо-дома и «чего на стенде нет», ручной
чек-лист снят (ручной фазы в процессе нет). ISOMETRIC.md — только текущее;
история Stage 2/4 — docs/adr/570-isometric-stage4-visual-handoff.md.
SUN.md: удалённый контракт фона снят, правило бумаги — в текущем разделе.
UX-MODES.md: декор над заливками, а не «под комнатами»; «hidden isometric»;
follow-up из #3 — все выпущены. Шапки VACUUM, WARM-REMOUNT («Выровнять всё
по сетке» → «Оптимизировать планы»), WALL-THICKNESS, STYLING-HOOKS,
CONFIG-COMPATIBILITY (#33), PDF-EXPORT — без устаревших статусов и планов.
README EN/RU: абзац про пересъёмку скриншотов → CONTRIBUTING.md, RADAR и
PDF-EXPORT в списке документации, RU догнал EN (2.5D, повторное
использование загруженного изображения, STAIRS). Один список канонических
документов подсистем в AGENTS.md и промпте ревьюера (_process.yml).
WALL-THICKNESS.md ссылается на ADR 282.

Правки src/** и validation.py — только пути документов в комментариях.

Issue: #679
User-Visible: no
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-27 18:07:37 +00:00

23 KiB
Raw Permalink Blame History

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

Код: 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). Все они живут внутри одного жеста; пересоздание элемента жест и так прерывает.