23 KiB
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 файлов) — по существу три группы:
- Продукт (класс A):
src/summary-runtime-loader.ts(новый, 143 строки) + 8 строк вsrc/houseplan-card.ts(подключениеSummaryRuntimeSlot/summaryRuntimeLoaderвconnectedCallback/disconnectedCallback, замена прежнегоimport().then()). - Гейты/инструменты (класс 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). - Документация (класс 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держитruntimeper-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 кадров пиксель-идентичны»; jobcheck-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— ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. - Дерево материала:
dc12b608285572d3f25abf88868f2546f4a9a34cgit log --all --format='%H %T' | grep dc12b6082855 - ТЗ
docs/specs/506-startup-performance.md, блобd2517458924164f801f493ff8597ebdd2a8e4e35git log --all --find-object=d2517458924164f801f493ff8597ebdd2a8e4e35 -- docs/specs/506-startup-performance.md - Вердикт конвейера:
green· High 0