Решили: подписки экрана живут в OnEnter/OnExit, а не в Build. Экран, успевший резолвнуться
внутри собственного Build, в стек не кладётся — навигатор спрашивает у него IsResolved.
Почему: Build вызывается, только пока Root == null, то есть один раз за сессию; показывают и
снимают экран сколько угодно раз. Пара «подписка в Build, отписка в OnExit» поэтому асимметрична
по своей природе: после первого закрытия подписок не остаётся, а вернуть их некому. Дев-консоль от
этого выглядела сломанной — команды выполнялись, но окно молчало; список логов застывал на строках
первого показа.
Отвергнутая альтернатива для второй половины — «отложить резолв за пределы Build через schedule»:
она чинит один известный экран и оставляет ловушку для следующего, кто решит всё в билд-лямбде.
Признак на экране закрывает класс.
Грабли: снятие экрана, которого ещё нет в стеке, проходит вхолостую и никак себя не проявляет.
RemoveScreen не находит экран, молча выходит — а Push следом кладёт уже резолвнутый экран и в
стек, и в слой. Панель главного меню оставалась лежать поверх мира до ближайшего PopAll. Тот же
класс — синхронный колбэк CancellationToken.Register на уже отменённом токене: он отрабатывает
ДО того, как навигатор успевает запомнить регистрацию, и запись оседает в словаре навсегда.
Владелец правды: UiScreen.IsResolved (с <remarks>), UiNavigator.Push,
DevConsoleScreen.OnEnter, DevLogScreen.OnEnter.