Files
houseplan-card/docs/reviews/CODE-REVIEW-341-r1.md
T
2026-08-28 11:52:12 +00:00

12 KiB
Raw Blame History

CODE-REVIEW — issue #341 · заход r1

Скоуп

Issue #341 (трек trivial, метки bug/P3/polish): аудит C5 нашёл, что src/labs.ts защищает подписку на hashchange/popstate только модульным флагом listening. Home Assistant может перезагрузить ресурс карточки без перезагрузки страницы (обновление через HACS, dev-режим, повторная регистрация ресурса) — новая инстанция модуля не видит старый флаг и добавляет вторую пару обработчиков на тот же window, что даёт повторную обработку одного события.

AC1–AC3 из тела issue:

  • AC1/AC2 — после двух инициализаций Labs в одном window одно событие hashchange/popstate вызывает обработчик только актуальной инициализации.
  • AC3 — обычная одиночная инициализация и разрешение флагов не меняются.

Диапазон: git log --oneline origin/dev..HEAD / git diff origin/dev...HEAD. Коммиты:

  • 5f6bddea fix: deduplicate Labs location listeners — src/labs.ts, test/labs.test.mjs.
  • 2b6db4c8 docs: refresh screenshot fingerprint — dist/**, custom_components/houseplan/frontend/**, docs/images/**, docs/images/screenshots.json (следствие правки src/**, не отдельная задача).

Продуктовая рамка (SCOPE.md): фикс внутренний, к Core user jobs не привязан напрямую — это защита контракта Labs («One module-level browser listener; card instances only subscribe to its snapshot»), которым управляется presentation-only флаг iso (#89, J1-презентация). Правка не меняет UX, URL/storage-грамматику Labs, жизненный цикл карточки — всё согласно разделу «Не входит» issue. Изменение точечное: только src/labs.ts (class A) + test/labs.test.mjs (class B) + сгенерированные class D/C артефакты. Постороннего кода в диффе нет.

Как проверялось

Дешёвые гейты на SHA 2b6db4c8 подтверждены Validate (https://github.com/Matysh/houseplan-card/actions/runs/33168458678, success) — npx tsc --noEmit, npm test, npm run build со сверкой бандла не перегонял.

Сам прогнал точечно (диапазон правки узкий, стоимость минимальна):

  • npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test test/labs.test.mjs → green, 6/6, включая новый тест «a reloaded Labs module replaces both browser location listeners».
  • Тест умеет падать: временно убрал вызов window.__hpLabsListenerCleanup?.() из собранного test-build/labs.js (эмуляция кода до фикса) и перезапустил — новый тест краснеет (2 !== 1, ожидание firstPublishes === 1, фактически 2, т.к. обработчик снятой инстанции продолжает публиковать на hashchange). Откатил файл обратно, git status подтвердил чистое дерево.
  • node scripts/check-docs.mjs → green (7 файлов, 10 внешних ссылок) — обязателен, т.к. диф трогает src/**.
  • Байт-в-байт сверка dist/** ↔ custom_components/houseplan/frontend/** (houseplan-card.js, houseplan-assets.json, все файлы houseplan-assets/*) → идентичны, bundle:sync не разошёлся.
  • node scripts/smoke-select.mjs --base origin/dev --head HEAD → НЕОПРЕДЕЛЁННОСТЬ: 1 файл src/**, ни один из 196 смоков не связан доказуемо; символ без покрытия — onLocationChange. Решение: не прогонять браузерные смоки — onLocationChange не рендерит и не меняет DOM/маршрутизацию, это чистая функция диспетчеризации на publishBrowserLabs, а контракт «ровно одна активная пара обработчиков» полностью воспроизведён и провёрен модульным тестом с двумя реальными ES-module инстанциями (через import(...'?listener-instance=...')), что ближе к реальному сценарию (повторная загрузка ресурса), чем любой браузерный смок мог бы дать.
  • Чтение кода (src/labs.ts:147–212): прослежен путь ensureBrowserLabs → window.__hpLabsListenerCleanup для 1, 2 и 3 последовательных инстанций — каждая новая инстанция снимает ровно предыдущую пару обработчиков (через замыкание cleanup, ссылающееся на её же onLocationChange) и кладёт на window свой cleanup; guard if (window.__hpLabsListenerCleanup === cleanup) предотвращает случайное затирание чужого cleanup при непоследовательном вызове. Повторный вызов ensureBrowserLabs в РАМКАХ ОДНОЙ инстанции (обычный случай — несколько карточек на одной странице) не трогает if (!listening) вовсе — поведение AC3 не изменено кодом, а не только заявлением.

Не прогонял: npm run golden:verify (диф не меняет рендер/геометрию/стили — onLocationChange не рисует ничего), npm run invariants (геометрия, layout, толщина стен, open_spans не затронуты), python -m pytest tests_backend (custom_components/**/*.py не менялся), performance-профили (не названы в AC, путь не в горячей точке рендера). npm run bundle:budget не перегонял — автор привёл число (256046 B при бюджете 282000 B) и разница от iso-строки правки незначительна; независимо сверил байт-идентичность копий бандла, что покрывает риск рассинхронизации сильнее, чем повторный бюджет-прогон.

Находки

Нет находок High или Medium. Мелких Low-находок, требующих правки или явного отказа, не нашёл — код и тест соответствуют предложенному в issue решению («хранить состояние подписки на window под приватным глобальным ключом») буквально.

Что проверено и корректно

  • AC1/AC2 доказаны автотестом, тест умеет падать (см. выше) — это не «verified» на честном слове, а воспроизведённый красный/зелёный цикл.
  • AC3 — код, отвечающий за одиночную инициализацию, не тронут (if (!listening) guard и его тело для случая true остаются пустой веткой); резолвер флагов (resolveLabs, liveLabsFlags и т.д.) вообще не в диффе — старые тесты резолвера все зелёные без изменений.
  • Три и более последовательных инстанций разобраны построчно — не только «два», как в тесте, — инвариант «ровно одна активная пара» держится по индукции для N инстанций.
  • window.__hpLabsListenerCleanup — новое глобальное свойство описано в declare global, именование согласовано с существующим __hpLabs, двойное подчёркивание держит приватность соглашения.
  • Labs-правила AGENTS.md соблюдены: изменение не вводит новый флаг, не трогает грамматику URL/storage, не гейтит данные/HA-действия — чисто presentation-инфраструктура регистрации слушателей.
  • Трейлеры обоих коммитов корректны: Issue: #341, User-Visible: no — согласился с оценкой автора: фикс невидим при обычном использовании (устраняет задвоение реакций на скрытый Labs-флаг iso, а не меняет какое-либо наблюдаемое поведение), changelog обоснованно не трогался.
  • Байты бандла (dist ↔ integration copy) и docs-фингерпринт (check-docs.mjs) сошлись — вторая правка 2b6db4c8 не оставила dev с красным docs-джобом (прецедент #230/#234 не повторился).
  • Диф нигде не выходит за нужные классы файлов: A (src/labs.ts), B (test/labs.test.mjs), C/D (сгенерированные скриншоты/бандл) — без постороннего кода.
  • Единственное новое число, видимое в тесте (счётчики публикаций), не дублирует какую-либо пользовательскую величину — сравнение «одно число, один источник» здесь неприменимо: change не вводит новых значений на экране.

Чего не проверял

  • Полный npm test/npm run build/npx tsc --noEmit по всему проекту — положился на зелёный Validate на этом же SHA (см. «Как проверялось»).
  • Браузерные смоки — инструмент дал НЕОПРЕДЕЛЁННОСТЬ, обосновал отказ выше; ручного захода в demo-стенд не делал.
  • golden, backend pytest, performance — вне зоны дифа, см. обоснование выше.
  • Реальный сценарий HACS-перезагрузки ресурса в живом Home Assistant — вне возможностей цикла ревью (нет ручного тестирования); заменено точной симуляцией через две независимые ES-module инстанции, что и является механизмом бага (новая инстанция модуля = новый listening).

Вердикт

Зелёный. Все три AC выполнены и доказаны (автотест + чтение кода), фикс не выходит за скоуп issue, гейты, относящиеся к дифу, зелёные, трейлеры и byte-sync корректны. Блокирующих находок нет.