13 KiB
Issue #113 — честный optional-контракт _spaceModel()
- Issue: https://github.com/Matysh/houseplan-card/issues/113
- Статус документа: готово к будущей реализации; issue остаётся на
S3-spec - Приоритет: P2
- Тип: tech-debt, обычный продуктовый трек
- Пользовательское изменение: нет; предотвращается повтор класса crash #111
1. Проблема
_spaceModel(id?): SpaceModel возвращает найденное пространство либо первый
элемент _model. При пустом массиве фактический результат — undefined, но тип
обещает SpaceModel. strict не ловит это без noUncheckedIndexedAccess.
#111 закрыл один вызов из render snapshot. Остальные consumers остаются
безопасными только благодаря текущему порядку render/lifecycle. Новый вызов до
empty-state gate может снова получить Cannot read properties of undefined.
2. Цели
- Типом зафиксировать отсутствие SpaceModel при
spaces: []. - Разобрать каждый production call site по допустимому empty behavior.
- Не заменять проблему non-null assertions или фиктивной моделью.
- Сохранить нормальное поведение всех editor/render paths при существующем пространстве.
- Расширить regression contract пустого плана за пределы одного snapshot.
3. Не входит в задачу
- изменение empty-state UX;
- автоматическое создание пространства;
- migration config/layout;
- включение
noUncheckedIndexedAccessдля всего репозитория; - рефакторинг всех методов
houseplan-card.ts; - изменение backend API;
- восстановление повреждённой модели с duplicate ids.
Глобальный noUncheckedIndexedAccess может быть отдельным tech-debt issue после
измерения diff. Он не должен незаметно расширить #113.
4. Нормативный API
_spaceModel() становится честно optional:
private _spaceModel(id?: string): SpaceModel | undefined {
const requested = id ?? this._space;
return this._model.find((space) => space.id === requested) ?? this._model[0];
}
Допустимо вынести pure selectSpaceModel(models, activeId, requestedId) для
unit. Возврат null вместо undefined допустим только единообразно; публичное
требование — тип не обещает объект.
Запрещено:
- возвращать synthetic empty
SpaceModel; - ставить
!у каждого consumer; - бросать исключение в обычном empty plan lifecycle;
- использовать
this._serverCfg.spaces[0]как второй скрытый fallback.
5. Классы call sites
5.1 Lifecycle до render gate
willUpdate, updated, snapshot capture, theme/paper resolver, frame/viewport,
mode transition, timers, ResizeObserver и WS handlers обязаны принимать
отсутствие модели.
Нормативное поведение:
- не строить geometry/devices/openings;
- очистить transient tooltip/hover/drag/dialog state, относящееся к удалённому пространству;
- не писать config/layout;
- не запускать service call;
- сохранить рабочий empty-state и кнопку создания пространства.
5.2 Event handlers редакторов
Draw/split/resize/opening/decor/device handlers начинают с локального guard:
const space = this._spaceModel();
if (!space) return;
Guard расположен до pointer capture, history mutation, optimistic mutation и persist. Если активный drag потерял пространство, вызывается соответствующий abort path, а не частичный commit.
5.3 Pure render helpers
Методы, формирующие TemplateResult/geometry, при отсутствии space возвращают
пустой layer/empty array/fallback frame согласно типу. Они не создают dummy room
или wall. Default parameters вида space = this._spaceModel() удаляются, если
их тип больше не гарантирует объект.
5.4 Paths с уже доказанным space
После единственного локального guard объект передаётся параметром вниз по
вызовам. Не нужно повторять lookup и optional chaining десятки раз. Такой
локальный narrowing предпочтительнее нового throwing _requireSpaceModel().
Strict helper допускается только в pure internal function, если caller уже
передал SpaceModel; метод карточки не должен падать на пользовательском empty
state.
6. Active id и fallback
Если _model непуст, но текущий _space отсутствует после adoption новой
конфигурации, сохраняется нынешний fallback на первый space. Lifecycle, который
принимает новую model, затем нормализует active id обычным путём.
Явный id:
- возвращает exact space при наличии;
- если id отсутствует, fallback на текущий первый элемент сохраняет legacy semantics только там, где caller намеренно просит display fallback;
- callers, для которых missing explicit id означает stale object, должны проверять identity отдельно и abort-ить, а не редактировать первый этаж.
Чтобы исключить опасную двусмысленность, допустимо разделить API:
_spaceModel()— active-or-first optional;_spaceModelById(id)— exact optional без fallback.
Это техническое разделение рекомендуется для drag/history/dialog commands,
содержащих stable spaceId.
7. Empty-state cleanup
При переходе non-empty → empty очищаются или отменяются:
_tip,_hoverRoom, opening/info transient cards;- active pointer/pan/pinch/drag и pointer capture;
- editor drafts/selections, которые ссылаются на удалённый space;
- projection/room-fit transition текущего space;
- pending persist debounce, если у него нет валидного target.
Глобальные config dialogs, которые создают новое пространство, остаются доступны. Cleanup не удаляет server files, layout другого пространства или пользовательский backup.
8. Type-system guard
После смены return type npm run typecheck обязан заставить обработать все
production consumers. Исправление не считается полным, если ошибки погашены
массовым ?. без определения поведения.
Добавляется source-contract test:
_spaceModelобъявлен optional;- в production source нет
_spaceModel()!иthis._spaceModel()!; - опасные default parameters не возвращают optional как required;
- empty-sensitive lifecycle helpers покрыты executable tests.
Source test — дополнительный guard, а не замена typecheck/code review.
9. Совместимость, touch и security
- visible UI с одним/несколькими spaces pixel-identical;
- config/layout schema и revisions не меняются;
- empty View одинаково безопасен на desktop/touch/kiosk/read-only;
- editor touch safety floor не меняется;
- нет новых service calls или permissions;
- удаление последнего space не удаляет файл подложки автоматически.
10. Acceptance criteria
_spaceModel/exact variant возвращает optional тип.- Все production call sites компилируются без non-null assertions на lookup.
- Empty render/update/resize/theme/WS cycles не бросают исключений.
- Удаление последнего space оставляет рабочий empty-state.
- Pending pointer/drag/editor action при исчезновении space abort-ится без history/persist/service call.
- Read-only cold start с
spaces: []остаётся полным и стабильным. - Non-empty View/editors сохраняют текущие pixels/actions.
- Missing explicit stale space id не мутирует первый space.
- Active-id fallback при непустой model сохраняет согласованное legacy behavior.
- Type/source gates не позволяют вернуть прежнюю ложную сигнатуру.
11. План тестирования
Unit
- selector: empty, active match, requested match, missing active, missing exact;
- exact lookup не падает в first-space fallback;
- cleanup state transition non-empty → empty;
- optional geometry helpers возвращают safe empty result.
Browser smoke
- cold load с zero spaces;
- удалить единственное пространство и дождаться нескольких Lit/RAF cycles;
- theme/resize/visibility/registry/WS update после удаления;
- empty → create first space → View/editor usable;
- удалить space во время pointer drag/mode transition;
- read-only empty config;
- no console errors/unhandled promise rejection.
Регрессия
- targeted smoke #111 и #131;
- View/Plan/Devices/Backdrop open-close cycle с одним и несколькими spaces;
- typecheck, full unit и build;
- mutation: вернуть required signature/unguarded lifecycle read — тест красный.
Golden не требуется при нулевом visual diff. Существующий empty-state screenshot может использоваться как regression evidence без новой переакцептации.
12. План реализации
- Ввести optional active и exact lookup helpers с unit tests.
- Поменять type и классифицировать compile errors по §5.
- Исправить lifecycle/render paths.
- Исправить editor handlers и передавать narrowed space вниз.
- Добавить empty-state cleanup и smoke.
- Добавить source/mutation contract и прогнать гейты.
13. Документация и release-артефакты
- changelog не нужен: ожидаемый user-visible behavior не меняется
(
User-Visible: no); docs/ARCHITECTURE.mdфиксирует optional active-space invariant;docs/TESTING.mdрасширяет empty-plan lifecycle matrix;- user guide и i18n не меняются;
- golden/screenshots не требуются при pixel parity;
- performance gate нужен только если diff затронет hot render helpers;
- backend HA harness не требуется.
14. Риски и откат
| Риск | Мера |
|---|---|
| Optional chaining скрывает частичный mutation | guard до side effects |
| Stale id редактирует первый space | exact lookup для commands |
| Cleanup закрывает Create flow | отдельная global/space-bound state matrix |
| Массовый diff меняет pixels | targeted smoke и existing golden parity |
| Ложный тип возвращается позже | source + mutation guard |
Откат возможен без data migration, но возвращать required signature допустимо только вместе с доказанным total/sentinel API, которого #113 не вводит.
15. Принятые технические предположения
- глобальный
noUncheckedIndexedAccessостаётся отдельной задачей; - exact lookup вводится для stable-id commands, active lookup сохраняет legacy first-space fallback при непустой model;
- empty-state visual design не меняется;
- guards группируются на границах, а не размазываются optional chain по каждому полю.