Files
houseplan-card/docs/reviews/CODE-REVIEW-506-r1.md
T
2026-09-09 11:35:33 +00:00

23 KiB
Raw Blame History

CODE-REVIEW-506-r1

Issue: #506 — «v1.73.0: лишние перерисовки при запуске плана блокируют Full Performance». Этап: код-ревью (PROCESS.md §2.7). Заход: r1. Блокирующих циклов израсходовано 0 из 4. Материал ревью: диапазон origin/dev..HEAD, вершина 9d6ac98d3a4256ec550962028d65813604d0f130 («fix: attach a warm summary runtime before the first render (#506)»). Ветка issue/506-startup-performance. ТЗ: docs/specs/506-startup-performance.md, ревью ТЗ зелёное на e17cdb51 (docs/reviews/SPEC-REVIEW-506-r1.md, вердикт: зелёный, High 0).

Скоуп

Diff (59 файлов) — по существу три группы:

  1. Продукт (класс A): src/summary-runtime-loader.ts (новый, 143 строки) + 8 строк в src/houseplan-card.ts (подключение SummaryRuntimeSlot/summaryRuntimeLoader в connectedCallback/disconnectedCallback, замена прежнего import().then()).
  2. Гейты/инструменты (класс B): test/summary-runtime-loader.test.mjs (новый), demo/smoke_summary_warm_attach.mjs (новый), demo/smoke_summary_panel.mjs (readiness-условие витринного смока), scripts/mutation-gate.mjs (+1 мутант), scripts/bundle-budget.mjs (перецентровка потолка), tsconfig.test.json (+1 файл в include).
  3. Документация (класс C) и сгенерированное (класс D): docs/ARCHITECTURE.md, оба CHANGELOG*, docs/images/screenshots.json (только fingerprint, см. ниже), dist/**, custom_components/houseplan/frontend/** (бандл-промоушен), плюс класс-D спецификация/ревью ТЗ, уже принятые предыдущим этапом.

Соответствует ТЗ §3 (объём задачи): загрузчик/фабрика runtime, устранение позднего header roundtrip, независимость карточек, узкие unit/browser/mutation проверки, неизменные performance-пары. Не задето: #500, дизайн/опции панели, backend, геометрия света, continuity/camera — подтверждено чтением диффа, посторонних файлов нет.

Коммит один (9d6ac98d), трейлеры Issue: #506 / User-Visible: yes на месте (git show -s --format=full), оба changelog (docs/CHANGELOG.md, docs/CHANGELOG.ru.md) правлены в этом же коммите — требование §2.6/№10 выполнено.

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

Свежая сессия без контекста реализации; вердикт состязательный, не пересказ хендоффа автора.

Что уже было зелёным на этом SHA (проверено, не повторялось)

Validate 9d6ac98d: https://github.com/Matysh/houseplan-card/actions/runs/34343078025 — success. Я прочитал состав этого прогона по логам и джобам, а не принял «success» на слово:

Джоб Вывод
Предполётные (докс/провенанс/process-gate) success
Фронтенд: типы, юниты, мутанты, синхрон бандла success — значит tsc --noEmit, npm test, npm run build + сверка копий на этом SHA уже зелёные
Мутанты по диффу (3 шарда) success — CI уже прогнал «затронутые» мутанты, включая новый
Переиспользование (reuse) success, но по логу — cache miss на всех четырёх маркерах (smoke/golden/performance_smoke/backend): реального переиспользования не было
Перф-смок, Смоки в браузере, Golden, Бэкенд pytest skipped

Важная находка по механике, не по коду: событие — push в ветку issue/506-startup-performance (не PR, без трейлера Release:), поэтому scripts/classify-changes.mjs --heavy вернул heavy=false, и тяжёлые джобы (перф-смок, полная браузерная матрица, golden, backend) в этом прогоне не выполнялись вовсе — это штатное поведение (validate.yml условие if: needs.changes.outputs.heavy == 'true', PROCESS.md §8: «тяжёлые job только на PR/кандидате/по кнопке»), а не пропуск дефекта. Значит фраза «Validate зелёный» покрывает дешёвые гейты и уже прогнанные по диффу мутанты, но не перф-смок и не общую браузерную матрицу — эти два я закрыл сам ниже.

Что прогнал сам на 9d6ac98d

Гейт Команда Результат
Applicability нового мутанта node scripts/mutation-gate.mjs --check --id=summary-runtime-attaches-after-first-render ok
Негативный свидетель мутанта node scripts/mutation-gate.mjs --id=summary-runtime-attaches-after-first-render чистый прогон зелёный, «summary-runtime-attaches-after-first-render: тест покраснел, как обязан» → «поймано 1 из 1» (независимо от заявления автора и от джобы changed_mutants в CI)
npx tsc -p tsconfig.test.json — 0 ошибок
npm run build && npm run bundle:sync — сборка ok; cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js — идентичны; git status после сборки чист (третья копия demo/srv/assets тоже совпала побайтно)
node --test test/summary-runtime-loader.test.mjs (после fix-test-build.mjs) — 5/5 pass (AC1–AC3: разные instance у разных host; общий pending import без общего instance; отменённая попытка не доходит до host, реконнект подключает один раз; провал импорта не кешируется, следующий attach повторяет; reset())
node scripts/no-new-any.mjs --base origin/dev --head HEAD — «Новых any нет» (147 новых строк в 2 файлах)
node scripts/bundle-budget.mjs — initial View 298 891 Б при потолке 299 600±2000 — совпадает с заявленным в коммите
demo/smoke_summary_warm_attach.mjs (новый, AC4/AC5/AC6) — все 17 проверок true, включая позитивного свидетеля реального resize
demo/smoke_summary_panel.mjs, demo/smoke_summary_panel_polish.mjs (зарегистрированная связь по smoke-select) — OK
demo/smoke_warm_remount.mjs, demo/smoke_visual_continuity.mjs, demo/smoke_isometric_contract.mjs — OK (перепроверены независимо от хендоффа автора)
4 смока с «прямым совпадением» по диффу, которых автор не прогонял: smoke_danger_confirm_branches, smoke_dialog_modal_recovery, smoke_space_card_bg, smoke_sun_live_bg (вывод smoke-select.mjs, символы connectedCallback/disconnectedCallback/_dayCycleTimer) — все 4 OK. Прочитал каждый файл: совпадение — по обобщённым именам методов жизненного цикла, изменённые строки диффа с их предметной областью (диалоги, фон space-карточки, Sun-фон) не связаны; прогнал их не по необходимости уже доказанного риска, а чтобы не оставлять непроверенный «прямой» вывод инструмента без решения
Перф-смок (пропущенный CI на этом SHA), 3 профиля npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --variants=60 --samples=3 --warmups=1 + compare --budgets=demo/performance/budgets-glow-smoke.json; то же для large-space-card-glow-v1; npm run benchmark:large-house -- --profile=large-house-interaction-v1 --samples=3 --warmups=1 + compare --budgets=demo/performance/budgets-interaction-smoke.json (профиль включён classify-changes.mjs из-за правки houseplan-card.ts) Все абсолютные потолки пройдены: overlay longTask.countP95 1/12, stateUpdate60Ms.median 294/2200 мс; space-glow аналогично; interaction-v1 longTask.countP95 15/30, maxSingleMs 1156/3000 мс, switchCycleMs.median 2065/7000 мс — везде запас, ни один показатель не у потолка

Это именно тот класс проверки, которого не хватало: перф-смок — быстрый (без парного sameRunner-сравнения) абсолютный потолок, который по PROCESS.md §8 «ловит регрессию в разы ещё в ревью» (пример #160). Он не выполнялся в CI на этом SHA (обычный push), и я закрыл этот пробел сам, а не принял отсутствие данных.

Защитный AC — таблица «чем краснеет» (правило #435)

AC Чем доказан Чем краснеет
AC1 (синхронный fresh runtime на прогретой фабрике) test/summary-runtime-loader.test.mjs тест «AC1», demo/smoke_summary_warm_attach.mjs (warmReplacementRuntimeBeforeFirstRender, coldKeyRuntimeBeforeFirstRender) мутант summary-runtime-attaches-after-first-render (снимает синхронный fast path целиком) — прогнан лично, «поймано 1 из 1»
AC4/AC5 (нет позднего header/refit-каскада на прогретой странице) demo/smoke_summary_warm_attach.mjs (warmReplacementHeaderStableFromFirstFrame, warmReplacementNoStageResize, firstTickSingleRender, firstTickNoStageResize) тот же мутант — тот же смок как guard в mutation-gate.mjs, лично воспроизведено красным
AC2 (нет дублирующего instance/connect при cold-race и отмене) unit-тесты «AC2» (3 сценария) для чистых юнитов правилом допускается прогон со снятой защитой в документе, а не обязательный мутант — прочитан код: без гарда live/if (this.runtime) return в adopt() тест «cancelled attachment» и «concurrent cold mounts» упадёт по построению (двойной push в seen/срабатывание отменённого коллбэка)
AC6 (реальный resize по-прежнему открывает continuity) demo/smoke_summary_warm_attach.mjs (realResizeOpensStageResize, realResizeStageFollows) — позитивный свидетель не защитный AC в обратную сторону (расположение/поведение, не отказ), отдельного мутанта не требует — п. #435 «не защищающие»

Находки

Блокирующих (High) нет. Medium в скоупе и вне скоупа не найдено.

Один пункт зафиксирован явно, но не как находка — как унаследованный по ТЗ, преднамеренно отложенный риск:

Not-a-finding: официальный парный Full Performance (7 образцов+1 прогрев, performance.yml, база fff171c7/эквивалент v1.72.0) не прогнан на 9d6ac98d. Автор прогнал только локальный приближённый прогон (сэндбокс, --samples=3/2 --warmups=1, CPU×4) и прямо пишет: для isometric «предел ×1.2 по count всё ещё может не проходить» — не заявляет «verified», а честно признаёт неопределённость. Это ровно тот сценарий, который ТЗ §7 предусмотрело заранее: «если гипотеза не закрывает isometric — stable остаётся заблокирован, результаты фиксируются в issue, бюджеты не ослабляются» — риск закрывается не этим код-ревью, а гейтом стабильного релиза (PROCESS.md §8: «Гейт стабильного релиза: полный локальный прогон плюс Validate и Full Performance зелёные на точном SHA»), который идёт после S8/беты. Дешёвый суррогат этого же класса регрессии (перф-смок, три профиля) я прогнал сам выше и он зелёный с запасом — по нему катастрофического отката нет. Не понижаю вердикт: это не пропуск, а корректно спроектированная отложенная проверка, зафиксированная тут, чтобы не превратиться в молчаливое доверие при подготовке стабильного релиза.

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

  • Контракт владения (ТЗ §4.1). SummaryRuntimeLoader кеширует только фабрику (factory/pending), SummaryRuntimeSlot держит runtime per-host. LoadedSummaryPanelRuntime (читал summary-panel-runtime-loaded.ts:66-97) не имеет статических/модульных полей — конструктор пишет только this.host, все состояния (local, dialog, stage, presentation, таймеры) — per-instance. Значит побочный, никогда не подключённый instance (проигравший гонку при повторном connect во время pending import) безопасен: конструктор не создаёт подписок/таймеров, они появляются только в .connect().
  • Синхронность до первого рендера. _summarySlot.connect() вызывается сразу после super.connectedCallback(), синхронно в теле connectedCallback; Lit планирует первый апдейт микрозадачей после возврата из connectedCallback, поэтому синхронный путь гарантированно предшествует первому render()/updated(). Проверено чтением src/houseplan-card.ts:2295, 2628-2629 и подтверждено смоком (!c2.hasUpdated в момент проверки _summary).
  • Same-node reconnect. disconnect() не обнуляет this.runtime, connect() при существующем runtime просто вызывает .connect() повторно — переиспользование instance гарантировано структурой кода (проверено чтением, не отдельным тестом — тривиальный инвариант, не защитный AC в терминах #435).
  • Инвалидация подписки (AC3). _capturedSnapshotSequence = -1 выставляется в колбэке adopted, который срабатывает ровно один раз на host (гард if (this.runtime) return в adopt()) — тот же побочный эффект, что был в старом import().then()-коде, просто перенесённый.
  • Ленивая граница (§4.1, «static import недопустим»). В houseplan-card.ts статически импортирован только сам загрузчик (~140 строк, без тяжёлых зависимостей); summary-panel-runtime-loaded по-прежнему подключается через import() внутри фабрики. Подтверждено бандл-бюджетом: initial View выросло всего на 299 Б (только загрузчик), summary-panel-runtime-loaded-*.js остаётся отдельным chunk-файлом в dist/houseplan-assets/.
  • Ядро карточки — 13 699 строк против потолка 13 700 (test/core-file-budget.test.mjs) — не задет вынесением в модуль.
  • Docs fingerprint. docs/images/screenshots.json: у всех сценариев изменился только sourceFingerprint/sourceSha256, ни один imageSha256 не изменился — совпадает с заявлением «11 кадров пиксель-идентичны»; job check-docs в Validate это же и проверяет и был зелёным (не warn, а реальный success шага, который иначе завалил бы «Вердикт предполётных проверок» через exit $fail).
  • Changelog/трейлеры/ветка/i18n — все формальные требования §2.6/§7.1/№10 выполнены, i18n не затронут (нет новых пользовательских решений/строк).

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

  • Официальный Full Performance (9 профилей / 3 нужных по этой задаче, 7+1 на Linux-раннере, performance.yml) — не прогонял и не запускал через run_workflow: по PROCESS.md §8 это гейт стабильного релиза, а не код-ревью, автор сам не смог его продиспатчить (недостаточные права токена) и явно передал это владельцу — см. раздел «Находки» выше, риск явно назван, а не спрятан.
  • python -m pytest tests_backend — не запускал: диффа в custom_components/**/*.py нет.
  • node scripts/model-invariants.mjs — не запускал: диффа в геометрии/layout/marker.space/open_spans нет, симптом задачи и правка целиком в lifecycle сводной панели.
  • npm run golden:verify — не запускал: диффа в рендер-логике/стилях/геометрии нет (только lifecycle подключения панели), а фингерпринт-доказательство docs-скриншотов (0 отличающихся пикселей на 11 кадрах) уже закрывает вопрос «видимый результат не изменился» для затронутых сценариев сильнее, чем повторный кадр golden дал бы для несвязанных сценариев.
  • 228 из 236 смоков матрицы (ls demo/smoke_*.mjs | wc -l = 236 на дату ревью) — вне «прямого совпадения»/«зарегистрированной связи» по smoke-select.mjs; полный прогон матрицы — предрелизный гейт (PROCESS.md §8), не гейт ревью, а диапазон дельты (lifecycle одного нового модуля + 8 строк в houseplan-card.ts) не даёт оснований подозревать более широкое пересечение.
  • npm run benchmark:junction-limits, benchmark:wall-draw-click — часть того же CI-джоба перф-смока, но не относятся к диффу (геометрия стыков/стен не тронута) — не прогонял, посчитал избыточным.

Вердикт

Зелёный. Изменение реализует контракт ТЗ §4 буквально: код кешируется постранично, состояние — per-host, синхронный fast path подтверждён и чтением, и personally воспроизведённым красным мутантом, асинхронный lifecycle (гонки cold/pending/disconnect) закрыт пятью юнит-тестами, ленивая граница и лимит ядра сохранены, budget/docs-fingerprint пересчитаны корректно и обоснованно, оба changelog на месте в одном коммите с верными трейлерами. Дешёвые гейты, пропущенные CI на этом SHA как обычном push (перф-смок, часть браузерной матрицы), прогнаны лично и все зелёные с запасом. Единственный непокрытый на этом SHA пункт — официальный парный Full Performance — по процессу и по самому ТЗ является гейтом стабильного релиза, а не код-ревью, и явно зафиксирован здесь как открытый риск, а не молчаливо пропущен.



Материал раунда

  • Ветка: issue/506-startup-performance, коммит 9d6ac98d3a42 — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
  • Дерево материала: dc12b608285572d3f25abf88868f2546f4a9a34c
    git log --all --format='%H %T' | grep dc12b6082855
    
  • ТЗ docs/specs/506-startup-performance.md, блоб d2517458924164f801f493ff8597ebdd2a8e4e35
    git log --all --find-object=d2517458924164f801f493ff8597ebdd2a8e4e35 -- docs/specs/506-startup-performance.md
    
  • Вердикт конвейера: green · High 0