23 KiB
SPEC-REVIEW-353-r1
Issue: #353 — «Устойчивость lazy-загрузки: терминальный отказ редактора после
сетевого сбоя и мёртвый entry-лоадер после обновления»
Этап: ревью ТЗ (PROCESS.md §2.4), лёгкий трек (small), заход r1,
блокирующих циклов израсходовано 0 из 2 (лимит §4: 2).
ТЗ живёт в теле issue #353 (лёгкий трек, файла в docs/specs/ нет).
Материал ревью: тело issue #353 на момент разбора (комментарий
Matysh, S2→S3, 2026-08-28T11:01:48Z), метки bug · P1 · S4-spec-review · small.
Вердикт
Жёлтый. High: 1 · Medium: 1 (в скоупе) · Low: 1 (снят с запиской, см. ниже). Причина: K3 (устойчивый entry) как буквально описан ломает инвариант, на который опирается сам демо-стенд, а значит и весь браузерный конвейер гейтов, построенный поверх него — при этом ни один AC этого не ловит. Остальной контракт (K1, K2, K4, K5) и критерии приёмки методологически крепкие и не требуют доработки.
Скоуп разбора
Первый заход — разбор полный, весь текст ТЗ в теле issue #353. Раздел «Унаследовано» и «Закрытие предыдущего раунда» не нужны — предыдущих раундов нет.
Как проверялось
- Прочитаны
docs/SCOPE.md,AGENTS.md,PROCESS.md(полностью, включая §2.4, §2.9/2.10, §5, §7.1, §8). - Прочитано тело issue #353 и единственный комментарий (переход S2→S3).
- Прочитан связанный issue #337 (родительский контракт lazy-бандла) для понимания, что «терминально»/«лениво» значило до этой задачи.
- Построчно сверены все технические утверждения ТЗ с текущим кодом:
src/editor-runtime-loader.ts(классEditorRuntimeLoader, текущая семантика retry/terminal);src/houseplan-card.ts:802-930(оба лоадера,_requestMode, тостыeditor.load_failed/editor.refresh_advice,customElements.defineна строке 12889-12890);dist/houseplan-card.js— реальное содержимое собранного entry-чанка:globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="...";export{d6 as HouseplanCard}from"./houseplan-assets/houseplan-card-BarpA18i.js";(подтверждает описание N3 буквально);rollup.config.mjs,scripts/bundle-manifest.mjs(editorRuntimeRetryUrlPlugin,bundleManifestPlugin,buildBundleManifest— как считаютсяinitial/lazyграфы);scripts/bundle-tree.mjs(verifyBundleTree— подтверждён пробел N4: проверяет только файлы ИЗ манифеста, не сканирует каталог на лишнее);scripts/bundle-budget.mjs+test/bundle-assets.test.mjs(гейт #352, как считаетсяinitialViewGzipBytes);custom_components/houseplan/frontend_assets.py,frontend_asset_manifest.py,__init__.py:85-132(текущиеCache-Control, fail-closed резолвер, регистрацияres_type: module);demo/srv/demo.html:180-209(загрузка карточки демо-стендом — ключевая находка, см. ниже);test/editor-runtime-loader.test.mjs(существующее покрытие: retry ровно один раз для любой ошибки, терминальность только для mismatch — подтверждает описание N2 буквально);src/i18n/en.json,ru.json,de.json(текущиеeditor.load_failed/editor.refresh_advice, три словаря синхронны, новый ключ вписывается по образцу).
git status/git log— рабочее дерево чистое на07b6a1f2(=origin/dev), веткиissue/353-*нет: код по этой задаче ещё не начат, что ожидаемо на этапеS4-spec-review.
Гейты — что прогнано и почему
Этап — ревью ТЗ, кода по issue #353 в дереве нет (задача ещё не покидала
S4-spec-review, ветки issue/353-* не существует). typecheck/test/
build/check-docs/smoke-select/model-invariants — гейты код-ревью
(PROCESS.md §8, §2.7) и относятся к диффу кода, которого здесь нет; на этапе
ТЗ (§2.4) прогонять их нечего и не о чем — сверять было бы не с чем.
Ничего не пропущено умалчиванием: раздел «Гейты» в этом документе не
применим к этапу spec, и это явное решение, а не пропуск.
Находки
[High] K3: замена статического реэкспорта на «голый» import().catch() рвёт гарантию await import(entry) ⇒ элемент зарегистрирован, на которую опирается сам демо-стенд
Файл/раздел: тело issue #353, раздел «К3 — entry переживает несвежесть».
Что не так. Сейчас dist/houseplan-card.js — это ES-реэкспорт:
export{d6 as HouseplanCard}from"./houseplan-assets/houseplan-card-BarpA18i.js";
Это статический import-edge: спецификация ES-модулей гарантирует, что
await import('/assets/houseplan-card.js') не резолвится, пока весь граф,
достижимый через export...from, не будет получен, слинкован и
выполнен — то есть пока customElements.define('houseplan-card', HouseplanCard) (строка houseplan-card.ts:12889-12890, лежит внутри
чанка houseplan-card-<hash>.js) гарантированно не отработает.
К3 предписывает заменить это на import("./houseplan-assets/…").catch(…)
— не export, а голый вызов внутри тела entry-чанка, судя по
формулировке и по аналогии с editorRuntimeRetryUrlPlugin (пост-обработка
уже сгенерированного chunk.code строковой заменой в generateBundle,
после того как Rollup уже посчитал граф). Promise такого import() никуда
не экспортируется и не await'ится потребителем entry-модуля. Значит,
top-level выполнение entry-чанка завершается почти сразу — до того, как
динамически запрошенный houseplan-card-<hash>.js успеет загрузиться и
выполниться, — и именно в этот момент резолвится await import('/assets/houseplan-card.js') у любого потребителя.
Демо-стенд, на котором держится вся браузерная часть гейтов, устроен именно
так — demo/srv/demo.html:193-197:
await import('/assets/houseplan-card.js');
const card=document.createElement('houseplan-card');
card.setConfig({type:'custom:houseplan-card', ...});
После К3 await import(...) резолвится раньше, чем элемент houseplan-card
зарегистрирован. document.createElement('houseplan-card') в этот момент
создаёт обычный неапгрейженный HTMLElement (в спецификации Custom Elements
апгрейд элемента до пользовательского класса происходит именно в момент
customElements.define, а не раньше) — метода setConfig на нём ещё нет.
Следующая строка card.setConfig(...) бросает TypeError: card.setConfig is not a function синхронно, в самом бутстрапе demo.html.
Почему это не мелочь и не ловится AC. Это не побочный редкий кейс —
это обычный, счастливый путь загрузки (когда главный чанк грузится
успешно). Он ломает бутстрап demo-стенда для каждого существующего
demo/smoke_*.mjs, для golden:capture/golden:verify, для
performance_smoke и для demo/docs/capture.mjs (снятие скриншотов
документации) — то есть весь браузерный слой CI одномоментно.
AC3 из ТЗ этого не поймает: (а) юнит-тест — это проверка текста
собранного файла на подстроки (dynamic import, catch, define-фолбэк),
не поведения при исполнении; (б) demo/smoke_entry_stale.mjs намеренно
воспроизводит испорченный главный чанк — сценарий, где import()
падает и код уходит в catch быстро; обычный работающий путь загрузки
(когда import() успешен) в AC не проверяется вовсе, а именно там гонка
и стреляет.
Как воспроизвести рассуждение (без исполнения — стадия ТЗ, кода ещё
нет): прочитать текущее содержимое dist/houseplan-card.js (статический
реэкспорт), заменить его мысленно на буквальный текст К3
(import(...).catch(...) без export/await), проследить, что
await import(entry) у потребителя больше не транзитивно ждёт вложенный
import(), и сопоставить это с demo/srv/demo.html:193-197, где
document.createElement + setConfig идут немедленно и синхронно после
await import(entry).
Что нужно поправить в ТЗ. Явно решить один из двух путей и записать его в контракт K3:
- либо entry сохраняет цепочку ожидания — например top-level
awaitвнутри самого чанка (await import(...).catch(...), ES2022 top-level await, Rollup formatesего поддерживает) с реэкспортом результата, чтобыawait import(entry)по-прежнему резолвился только после того, как главный чанк загружен либо фолбэк-элемент определён; это сохраняет сегодняшний инвариант без правок demo.html; - либо явно принять, что гонка есть, и добавить в объём задачи правку
demo/srv/demo.html(и любого другого потребителя entry, включая то, как HA резолвитres_type: module) на ожиданиеcustomElements.whenDefined('houseplan-card')вместо немедленногоdocument.createElement+setConfig— тогда это меняет объём файлов и, вероятно, поверхность (demo/**— класс B, но задачу это не выводит из лёгкого трека автоматически; решить нужно явно, а не молчанием).
Без этого уточнения ТЗ не выполнимо буквально: реализация по тексту К3
ломает работающий сегодня демо-стенд и, транзитивно, красит smoke,
golden, performance_smoke, docs — не после дефекта в проде, а сразу
на первом прогоне гейтов реализации.
[Medium, в скоупе] K2 не имеет ни одного AC/доказательства
Файл/раздел: тело issue #353, «К2 — тост на каждый неудачный цикл» vs раздел «AC и доказательства».
К2 — не мелочь: это ядро продуктового сценария issue-AC1 («каждая неудача
видима тостом», сетевая — новым ключом editor.retry_advice, терминальная
— старым editor.refresh_advice, для обоих лоадеров, editor и
onboarding). Но ни один из перечисленных AC1…AC4 его не проверяет:
- AC1 — юнит на уровне модуля
editor-runtime-loader.test.mjs, проверяет только сам классEditorRuntimeLoader(колбэкfailed(error, {terminal:false}), переход вidle) — не то, какой текст тоста выберетhouseplan-card.tsв своих двух колбэкахfailed: (error) => {...}(строки 832-835, 860-863), которые как раз и нужно переписать на(error, info) => info.terminal ? refresh_advice : retry_advice. - AC2 — про терминальность mismatch, тоже не про текст тоста.
- AC3/AC4 — про entry-чанк и orphan-проверку, к тостам отношения не имеют.
Реализация, которая продолжит всегда показывать refresh_advice
(игнорируя info.terminal) для обоих лоадеров, пройдёт весь названный
набор AC без единого красного теста — при том что это именно та регрессия,
из-за которой issue вообще завели (N2: «тост… показывается один раз… все
последующие нажатия — молчаливый no-op»; K2 — прямое лекарство от этого).
Фикс, полностью в скоупе задачи: добавить AC5, например: юнит на
уровне houseplan-card.ts (или на уровне колбэков failed, вынесенных в
проверяемую точку), который стабом задаёт failed(error, {terminal: true|false}) для _editorRuntimeLoader и _onboardingRuntimeLoader и
проверяет, что показанный тост содержит editor.refresh_advice в
терминальном случае и editor.retry_advice в сетевом, для обоих лоадеров
(4 комбинации). С High это не связано и не требует отдельного цикла как
самостоятельная находка — фиксируется тем же заходом, что и K3.
[Low, снято с запиской] docs/USER-GUIDE.ru.md не упомянут в объёме задачи, хотя описывает контракт, который эта задача меняет
docs/USER-GUIDE.ru.md:108-111 и :1740 документируют сегодняшнее
поведение («после одной повторной попытки… предлагает обновить страницу»;
«После обновления старая версия → перезапустите HA, перезагрузите
ресурсы/браузер»). После K1–K4 часть этого текста устареет: сетевой сбой
станет самостоятельно устраняемым повторным нажатием «Редактировать», а
несвежий entry — понятным баннером вместо тишины. ТЗ (как и полагается
лёгкому треку, §5) не обязано перечислять release-артефакты явно, а
docs/** — документация в тему той же задачи по DoD (PROCESS.md §2.6,
правило 11: «документация — в том же коммите, что поведение»), так что
формально это не пробел ТЗ. Снимаю находку записью здесь, а не как
блокирующую: автору стоит обновить эти два места в USER-GUIDE.ru.md в том
же коммите, что и код, но это не требует правки самого ТЗ.
Что проверено и корректно
- Скоуп и трек. Задача чинит регресс поведения уже принятого механизма #337 (терминальность/несвежесть lazy-доставки), не расширяет продукт — укладывается в J1/J4/J6 SCOPE.md («редакторы остаются доступны», «план остаётся верным»). Критерии лёгкого трека (§5) все выполнены формально: одна логическая поверхность (конвейер доставки lazy-бандла), риск заявлен ≤3, миграций конфига нет, новый UX-контракт не вводит новых элементов интерфейса (то же нажатие «Редактировать», тот же тост).
- K1 (терминальность loader'а). Логика «mismatch на ЛЮБОЙ попытке ⇒
терминально, иначе ⇒
idleи новый явныйensure()» согласована с существующим кодом_loadWithRetry(attempt 1 использует cache-buster — то есть mismatch на attempt 1 после успешного обхода кэша действительно означает версионный дрейф, а не транзиентный сетевой сбой) и с уже существующим тестом (editor-runtime-loader.test.mjs:61-83), который остаётся зелёным без правки ассертов. AC1/AC2 однозначны и проверяемы юнитом. - N4/K5 (осиротевшие чанки). Утверждение «
verifyBundleTreeне детектирует лишние файлы вне манифеста» подтверждено чтениемscripts/bundle-tree.mjs:38-53буквально — функция итерирует толькоmanifest.files, каталог не сканирует. Фикс и AC4 просты и корректны. - Проверенный факт вместо догадки. Утверждение К3 «именованный экспорт
entry исчезает — потребителей нет… проверено grep'ом» подтверждено
независимо: единственное место, где entry импортируется как модуль —
demo/srv/demo.html:193,await import(...)без захвата именованного экспорта; вcustom_components/**,test/**потребителей нет. Это не голословная догадка, а верно проверенный факт. - K4 (immutable Cache-Control). Согласуется с текущим кодом
(
frontend_assets.py:34—no-cacheменяется наpublic, max-age=31536000, immutable;__init__.py:100/109— entry остаётсяcache_headers=False, K4 явно это не трогает). Хэшированные имена чанков делают инвалидацию неактуальной проблемой — решение обратимо, откат корректен. - Откат. Один revert, конфиг/схема/миграции не затронуты — соответствует
фактическому масштабу изменения (два флага поведения + один rollup-плагин
- один HTTP-заголовок + одна проверка каталога).
- i18n. Новый ключ
editor.retry_adviceвписывается по образцу существующихeditor.load_failed/editor.refresh_advice, которые уже синхронны междуen.json/ru.json/de.json.
Чего не проверял
- Не прогонял
typecheck/test/build/check-docs/smoke-select/model-invariants— на этапе ревью ТЗ кода по issue #353 в дереве нет (веткаissue/353-*не создана), сравнивать не с чем; эти гейты относятся к код-ревью (§2.7, §8) и будут прогнаны в следующем цикле, когда появится диапазон коммитов. - Не оценивал реальное поведение HA Lovelace (
res_type: module) при гонке из находки High — оценка ограничена наблюдаемым фактом, что demo-стенд ломается гарантированно; поведение реального HA-фронтенда (который, вероятно, толерантен к асинхронной регистрации черезcustomElements.whenDefinedс таймаутом) не проверялось и не входит в этот репозиторий — не меняет вывода: даже если прод HA переживёт гонку, собственный демо-стенд проекта, на котором держится весь браузерный слой гейтов, не переживёт, и этого достаточно для блокирующей находки. - Не пересчитывал вручную содержимое
initialViewGzipBytes/бюджета #352 после гипотетической реализации K3: проверено чтениемbundle-manifest.mjs, чтоchunk.imports/chunk.dynamicImports— метаданные, которые Rollup фиксирует доgenerateBundle, и последующая строковая мутацияchunk.code(аналогично уже существующемуeditorRuntimeRetryUrlPlugin) их не меняет; отдельной находки по бюджету #352 в этом ТЗ не завожу, так как классификация initial/lazy остаётся корректной независимо от K3.