Эта статья — логическое продолжение моих экспериментов с реактивностью в JavaScript. На работе я решаю стандартные продуктовые задачи бизнеса. Но чтобы по-настоящему понять, как оптимизировать производительность под капотом, я написал свой реактивный движок достаточно простой для понимания, дебага и тестирования.

Проектирование fine-grained движка реактивности — это всегда прогулка по минному полю академических краевых кейсов. Но интеграция ленивого синхронного Pull/Push графа в Concurrent-рендерер React 18/19 со включенным StrictMode — это отдельный вид инженерного искусства.

Сегодня мы зафиксировали стабильный релиз @pravosleva/reactive-engine версии 2.2.0. Движок успешно сдал 171 сложнейший интеграционный тест в независимом кросс-фреймворк тест-сьюте Reactive Framework Test Suite. В сухих цифрах семантики мы встали в один ряд с признанными экосистемными решениями вроде @vue/reactivity (171), оставив позади базовые маркеры Solid.js (153) и нативного Angular Core (137)!

Но за этими цифрами стоит долгая неделя отладки реальных интерфейсов в браузере. В этой статье мы без лишнего пафоса разберем, какие неочевидные баги ломали нам рендеринг и как канонические паттерны помогли сделать код по-настоящему стабильным.

🐛 Ловушка 1: StrictMode в React и преждевременное выжигание графа

При отладке примера с цепочками асинхронных ресурсов мы столкнулись с тем, что в браузере интерфейс намертво зависал на статусе 🟡 loading..., хотя нативное ядро работало исправно. Причиной стало поведение React StrictMode (двойной монтаж эффектов).

В ранних версиях при падении количества подписчиков computed-ноды до нуля (this.size === 0) движок мгновенно вызывал releaseNode(), полностью стирая связи с родителями:

// 🛑 ОШИБКА, КОТОРАЯ ИЗ-ЗА STRICTMODE ЛОМАЛА ИНТЕРФЕЙС:
if (this.size === 0) {
  releaseNode(computedNode); // Преждевременное выжигание ребер графа
}

Анатомия бага:

React Concurrent Mode при монтировании хука useSyncExternalStore синхронно монтирует подписку, тут же её уничтожает и монтирует заново, чтобы проверить чистоту эффектов. Из-за этого лага количество подписчиков computed-ноды на наносекунду падало до нуля. Движок считал узел ненужным и навсегда отписывал его от родительских сигналов. Волна уведомлений markDirty больше не долетала до вычисления, и интерфейс замерзал.

Решение:

Мы перевели деструктуризацию связей графа на отложенную GC-стратегию. Кратковременные StrictMode-колебания подписчиков больше не выжигают рёбра. Полная зачистка происходит строго при явном вызове метода .destroy().

💎 Ловушка 2: Асимметричные алмазы (Jagged Diamonds) против Вложенных Эффектов

Следующий вызов бросил нам тест на инвариант «очистка до оценки» (тест #242). Если внутри тела computed динамически создаются вложенные inner-эффекты фреймворка, их функции очистки (cleanup) обязаны выполниться строго ДО того, как запустится тело новой функции оценки fn().

Попытка превентивно вызывать очистку зависимостей на старте метода .run() привела к «эффекту качелей» — упал канонический тест #3 (jagged diamond + tails):

S(a)
/  
C(b) C(c)
|    |
C(d)  |   <-- Асимметричная глубина веток (Jagged Depth)
\   /
C(e)

Анатомия бага:

Из-за асимметричной глубины веток пуш-волна долетала до точки схождения e через левое крыло быстрее, чем через правое. Очищая зависимости на старте .run(), узел e превентивно отписывался от правого крыла c, до которого волна сверху ещё просто не успела доползти. Правая ветка ошибочно считала себя сиротой, маркеры версий эпох сбивались, и каскад пулла падал в избыточные повторные вычисления.

Каноническое решение (Context Ownership):

Мы внедрили в computed-ноду изолированное контекстное хранилище ownedEffects. Перед запуском fn() узел каскадно уничтожает исключительно свои собственные внутренние эффекты, в то время как дифференциация и отписка внешних рёбер графа (oldDeps) происходит строго ПОСЛЕ выполнения вычисления, сохраняя топологическую целостность алмазов:

// 🟢 ИДЕАЛЬНЫЙ БАЛАНС ФАЗ В METOДЕ RUN():
ownedEffects.forEach(child => child.dispose()); // 1. Очистка внутренних эффектов ДО
const newValue = fn();                          // 2. Вычисление нового значения
oldDeps.forEach(dep => { ... });                // 3. Дифференциация внешнего графа ПОСЛЕ

Ловушка 3: Изоляция мёртвых ветвей и Diamond-Safe Pull Gate

Сложнейший тест #193 (Sequential Dirty Check) требует, чтобы при динамическом переключении условий мёртвые ветки графа вообще не просыпались и не накручивали счётчики вызовов.

Если использовать жёсткий break при проверке версий родителей, многосвязные асинхронные графы в браузере зависали, так как параллельные ветки не успевали сбросить стейты. Если убрать break — мёртвые ветки вычислялись холосто.

Каноническое решение (Мягкое версионное прерывание):

Мы внедрили последовательный опрос родителей по одному с мягким прерыванием по условию изменения версий (if (!hasChangedParent)):

// ⚡ DIAMOND-SAFE SEQUENTIAL PULL GATE:
for (const dep of Array.from(node.dependencies || [])) {
  const rawDep = dep._node || dep;
  
  if (!hasChangedParent) {
    if (rawDep.type === 'computed' && rawDep.state > 0) updateIfNecessary(rawDep);
    if (node.depVersions.get(dep) !== rawDep.version) {
      hasChangedParent = true; // 🎯 Мёртвая ветка ниже по циклу больше не опрашивается!
    }
  }
}
// Внутри useSyncExternalStore:
getSnapshot: () => {
  // Принудительно прогреваем ленивый граф в untracked-контексте прямо в снапшоте
  engine.untrack(() => signal.value);
  
  // Возвращаем глобальную эпоху движка для computed и proxy-коллекций
  return engine.epoch;
}

Анатомия интеграции:

Привязка снимка к engine.epoch с параллельным untrack-прогревом ленивых computed-нод гарантирует, что асинхронные батчи (например, прилёт данных по сети от MSW) мгновенно пробивают кэш React. Это полностью ликвидировало зависания и защитило Concurrent-поток от краша Maximum update depth exceeded.

Итоги независимого зачёта

Благодаря проведенной калибровке, архитектура @pravosleva/reactive-engine встала на один уровень с лидерами экосистемы:

  • ** alien-signals:** 178 / 178 (100%)
  • ** @preact/signals-core:** 174 / 178
  • ** @reatom/core:** 174 / 178
  • ** @pravosleva/reactive-engine (v2.2.0):** 171 / 178
  • ** @vue/reactivity:** 171 / 178
  • ** solid-js:** 153 / 178

Движок полностью готов к высоким нагрузкам, сложным ООП/DI паттернам и Concurrent-рендерингу. Уже пробовали fine-grained реактивность в связке с React 19? Делитесь вашим опытом в комментариях!


💾 Короткий текст коммита для релиза v2.2.0

release(stable): v2.2.0 — complete edge-cases calibration and fix react concurrent mode strictmode compliance