Skip to content

Потеря scope

Scope живёт в текущем контексте выполнения. Синхронный код внутри блока scoped(...), обработчика эффекта или тела реакции выполняется со scope в контексте. Потеря scope — это когда работа, которая должна принадлежать scope, выполняется без scope в контексте, потому что управление прошло через асинхронную границу, которую scope не пережил.

В отличие от некоторых библиотек, Virentia не откатывается молча к глобальному scope. Чтение или вызов без scope бросает Scope is required, поэтому потеря scope обычно падает громко и указывает на проблемный юнит. Это преимущество: вы узнаёте о проблеме на границе, а не тремя экранами позже через кусок состояния, который так и не обновился.

Как это выглядит

Типичный симптом — ошибка Scope is required из чтения стора или из вызова события/эффекта внутри колбэка, который выполнился позже:

text
Scope is required to call event "tick", but no scope is active.
Unit path that led here: … → call event "tick".

Модель таймера показывает это наглядно. Выглядит правильно, но бросает ошибку на каждый тик:

ts
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 / setInterval
  • addEventListener и другие колбэки DOM / эмиттеров событий
  • обработчики WebSocket / сокетов (socket.on(...))
  • .then(...) / сырой await fetch() / любой голый промис
  • сторонние асинхронные API, которые вызывают вас обратно

Общее: scope — это контекстное значение, установленное во время синхронного выполнения; сырой await или колбэк, вызванный извне, продолжается уже без этого контекста. Это асинхронность JavaScript, а не особенность Virentia — по той же причине пропало бы this или значение AsyncLocalStorage.

Как чинить

Последовательный async в правиле → используйте эффекты

Ожидание юнита сохраняет scope: после await someFx() или await someEvent() продолжение по-прежнему выполняется в том же scope. Поэтому оборачивайте внешнюю асинхронность в effect, а не await-ьте голый промис.

ts
// ❌ сырой 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 руками называть не нужно.

ts
import { scoped } from "@virentia/core";

reaction({
  on: started,
  run() {
    // `scoped()` захватывает scope, в котором выполняется эта реакция.
    const inScope = scoped();

    const id = setInterval(inScope.wrap(() => tick()), 1000);
    onCleanup(() => clearInterval(id));
  },
});

Тот же паттерн покрывает слушатели и сокеты:

ts
const inScope = scoped();
socket.on("message", inScope.wrap((msg) => messageReceived(msg)));
window.addEventListener("resize", inScope.wrap(() => resized()));

Создавайте это внутри owner и регистрируйте onCleanup, чтобы таймер/слушатель убирался вместе с моделью.

Границы → называйте scope явно

Когда вы запускаете юнит из теста, серверного загрузчика или адаптера фреймворка, не полагайтесь на ambient scope — передавайте его:

ts
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 для короткой версии.