Files
houseplan-card/docs/specs/113-optional-space-model.md
T
2026-08-19 02:47:50 +03:00

13 KiB
Raw Blame History

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. Цели

  1. Типом зафиксировать отсутствие SpaceModel при spaces: [].
  2. Разобрать каждый production call site по допустимому empty behavior.
  3. Не заменять проблему non-null assertions или фиктивной моделью.
  4. Сохранить нормальное поведение всех editor/render paths при существующем пространстве.
  5. Расширить 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

  1. _spaceModel/exact variant возвращает optional тип.
  2. Все production call sites компилируются без non-null assertions на lookup.
  3. Empty render/update/resize/theme/WS cycles не бросают исключений.
  4. Удаление последнего space оставляет рабочий empty-state.
  5. Pending pointer/drag/editor action при исчезновении space abort-ится без history/persist/service call.
  6. Read-only cold start с spaces: [] остаётся полным и стабильным.
  7. Non-empty View/editors сохраняют текущие pixels/actions.
  8. Missing explicit stale space id не мутирует первый space.
  9. Active-id fallback при непустой model сохраняет согласованное legacy behavior.
  10. 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. План реализации

  1. Ввести optional active и exact lookup helpers с unit tests.
  2. Поменять type и классифицировать compile errors по §5.
  3. Исправить lifecycle/render paths.
  4. Исправить editor handlers и передавать narrowed space вниз.
  5. Добавить empty-state cleanup и smoke.
  6. Добавить 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 по каждому полю.