Потеря scope
Scope живёт в текущем контексте выполнения. Синхронный код внутри блока scoped(...), обработчика эффекта или тела реакции выполняется со scope в контексте. Потеря scope — это когда работа, которая должна принадлежать scope, выполняется без scope в контексте, потому что управление прошло через асинхронную границу, которую scope не пережил.
В отличие от некоторых библиотек, Virentia не откатывается молча к глобальному scope. Чтение или вызов без scope бросает Scope is required, поэтому потеря scope обычно падает громко и указывает на проблемный юнит. Это преимущество: вы узнаёте о проблеме на границе, а не тремя экранами позже через кусок состояния, который так и не обновился.
Как это выглядит
Типичный симптом — ошибка Scope is required из чтения стора или из вызова события/эффекта внутри колбэка, который выполнился позже:
Scope is required to call event "tick", but no scope is active.
Unit path that led here: … → call event "tick".Модель таймера показывает это наглядно. Выглядит правильно, но бросает ошибку на каждый тик:
function createTimerModel() {
const tick = event<void>();
const seconds = store(0);
reaction({ on: tick, run() { seconds.value += 1; } });
// ❌ setInterval вызывает это позже, вне scope — `tick()` бросает ошибку.
setInterval(() => tick(), 1000);
return { seconds, tick };
}Интервал срабатывает после того, как синхронная настройка завершилась, поэтому в момент вызова tick() scope в контексте нет.
Где это происходит
Scope теряется на любой границе, где управление покидает ваше scope-выполнение и возвращается позже — обычно через колбэк, которым владеет другая система:
setTimeout/setIntervaladdEventListenerи другие колбэки DOM / эмиттеров событий- обработчики WebSocket / сокетов (
socket.on(...)) .then(...)/ сыройawait fetch()/ любой голый промис- сторонние асинхронные API, которые вызывают вас обратно
Общее: scope — это контекстное значение, установленное во время синхронного выполнения; сырой await или колбэк, вызванный извне, продолжается уже без этого контекста. Это асинхронность JavaScript, а не особенность Virentia — по той же причине пропало бы this или значение AsyncLocalStorage.
Как чинить
Последовательный async в правиле → используйте эффекты
Ожидание юнита сохраняет scope: после await someFx() или await someEvent() продолжение по-прежнему выполняется в том же scope. Поэтому оборачивайте внешнюю асинхронность в effect, а не await-ьте голый промис.
// ❌ сырой await роняет scope
reaction({
on: submitted,
async run() {
const res = await fetch("/api/save"); // scope потерян здесь
saved.value = await res.json(); // бросает: Scope is required
},
});
// ✅ эффект владеет запросом; ожидание эффекта сохраняет scope
const saveFx = effect(async () => (await fetch("/api/save")).json());
reaction({
on: submitted,
async run() {
saved.value = await saveFx(); // после await всё ещё в scope
},
});Это ответ почти для всей асинхронности в модели: внутри реакции или эффекта await-ьте только юниты (someFx(), someEvent(), scoped(...)), никогда — голый промис.
Внешние колбэки → scoped(scope).wrap
Когда другая система вызывает вас позже — таймер, слушатель событий, сокет — захватите scope и переоткройте его для этого колбэка через scoped().wrap(fn). Вызванный без аргумента-scope, scoped() захватывает scope, в котором он сейчас выполняется, и возвращает привязанный к нему runner; .wrap затем перезапускает ваш колбэк в этом scope, когда бы он ни был вызван. Scope руками называть не нужно.
import { scoped } from "@virentia/core";
reaction({
on: started,
run() {
// `scoped()` захватывает scope, в котором выполняется эта реакция.
const inScope = scoped();
const id = setInterval(inScope.wrap(() => tick()), 1000);
onCleanup(() => clearInterval(id));
},
});Тот же паттерн покрывает слушатели и сокеты:
const inScope = scoped();
socket.on("message", inScope.wrap((msg) => messageReceived(msg)));
window.addEventListener("resize", inScope.wrap(() => resized()));Создавайте это внутри owner и регистрируйте onCleanup, чтобы таймер/слушатель убирался вместе с моделью.
Границы → называйте scope явно
Когда вы запускаете юнит из теста, серверного загрузчика или адаптера фреймворка, не полагайтесь на ambient scope — передавайте его:
await scoped(appScope, () => submitted(form));
scoped(appScope, () => count.value++);Чек-лист
- Внутри реакции/эффекта
await-ьте только юниты. Голыйawait fetch()/await delay()означает, что вы вышли из scope — перенесите это вeffect. - Любой колбэк, отданный таймеру, слушателю, сокету или стороннему API, должен быть обёрнут:
scoped().wrap(cb). - Стройте обёртку через
scoped()там, где scope ещё активен (тело реакции, обработчик эффекта) — он сам захватит текущий scope, — а не позже внутри колбэка. - Убирайте таймеры/слушатели через
onCleanupвowner. - На границах системы передавайте scope явно через
scoped.
См. также Правила scope для короткой версии.