12 KiB
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.
Коммиты:
5f6bddeafix: deduplicate Labs location listeners —src/labs.ts,test/labs.test.mjs.2b6db4c8docs: 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; guardif (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 корректны. Блокирующих находок нет.