Repository navigation
🚀 Release v2.7.0
Проведен глобальный рефакторинг ядра и планировщика на основе обновленной методологии кросс-библиотечного набора тестов на семантику графа Reactive Framework Test Suite.
По результатам жесткого аудита семантики графа зависимостей (161 тест-кейс), движок успешно проходит **158 из 161 специализированных тестов.
Актуальная матрица надежности графа (Ноябрь 2026)
| Место | Framework | Версия | Pass Rate | Coverage | Pass | Fail | Skip | Total |
|---|---|---|---|---|---|---|---|---|
| 1 | alien-signals | 3.2.1 | 100.0% | 100.0% | 161 | 0 | 0 | 161 |
| 2 | @preact/signals-core | 1.14.5 | 99.4% | 100.0% | 160 | 1 | 0 | 161 |
| 3 | @vue/reactivity | 3.5.45 | 98.8% | 100.0% | 159 | 2 | 0 | 161 |
| 4 | tansu | 2.0.0 | 98.6% | 88.2% | 140 | 2 | 19 | 161 |
| 5 | @pravosleva/reactive-engine | 2.7.0 | 98.1% | 100.0% | 158 | 3 | 0 | 161 |
| 6 | signal-polyfill (TC39) | 0.2.2 | 97.1% | 84.5% | 132 | 4 | 25 | 161 |
🛠 Что нового в версии 2.7.0
Рефакторинг планировщика и изоляция ошибок (Фикс тестов #177 и #247)
- Атомарный сброс очереди: В метод
flushEffectsинтегрирована защита от протекания задач. Если пользовательский колбэк или деструктор выбрасывает исключение, планировщик принудительно гасит флаги грязи (isDirty) у всех оставшихся в очереди эффектов. - Бескомпромиссный проброс: Устранено скрытое замалчивание ошибок в фазе очистки. Любые исключения теперь беспрепятственно поднимаются наверх в ранер выполнения, не оставляя «грязных» следов в памяти и сохраняя поток
stderrкристально чистым.
Изоляция деструкторов и Snapshot-валидация (Фикс теста #40)
- Функции очистки (
cleanup) переведены в изолированный режимuntracked, полностью исключая утечки контекста и ложные переподписывания на сигналы во время утилизации нод. - Интегрирован механизм локального кэширования исторических значений на уровне ноды эффекта. При вызове сигналов напрямую из тела деструктора, замыкание гарантированно видит старый snapshot данных до начала текущего синхронного тика обновлений.
Жизненный цикл вложенных контекстов (Фикс тестов #209 и #210)
- Метод
runCleanupPhaseтеперь выполняется в условиях строгой изоляции контекстовtrackingиownership. - Дочерние эффекты, динамически создаваемые внутри очисток (например, в паттернах дебаунсинга), больше не наследуют битый контекст пересчитывающегося родителя. Они регистрируют честные независимые подписки и корректно уничтожаются каскадом (deepest first).
Запись в сигналы во время чтения computed (оставшиеся 3 упавших теста) по-прежнему сознательно интерпретируется движком как побочный антипаттерн, гарантируя 100% защиту классических паттернов проектирования приложений от проблемы ромба (Diamond Problem).
Анализ ограничений и упавших тестов (Секция Inner Write)
Три оставшихся падения (Тесты #180, #182 и #186) локализованы исключительно в специфической секции Inner Write. Они выявляют архитектурную особенность @pravosleva/reactive-engine: движок не поддерживает «грязные» мутации (записи в сигналы через write / .value =) непосредственно внутри синхронного pull-вычисления computed-свойств.
В чистой архитектуре реактивного программирования запись в стейт из тела вычисляемого свойства считается строгим антипаттерном (так как нарушает идемпотентность функций), и полностью запрещена или приводит к throw во многих экосистемах (Angular Signals, TC39 Signals Polyfill). Для @pravosleva/reactive-engine эти ограничения не являются критическими для статуса Production-Ready.
Ниже приведен детальный разбор этих сценариев, потенциальных проявлений на практике и способов их обхода.
Для поддержания стабильности графа в production необходимо разделять ответственность:
- Чего делать нельзя: модифицировать стейт, записывать в другие сигналы или производить побочные эффекты внутри computed.
- Как делать правильно: использовать computed как чистые функции для трансформации данных, а все побочные эффекты, логирование и мутации выносить в effect или resource.
Соблюдение этих правил исключает проблемы в секции Inner Write и гарантирует корректную работу графа без лишних рендеров.
❌ Тест #114: [предполагаю,] ложноположительный артефакт удаленной тестовой среды.
Согласно сводной матрице, тест #114 (inner write final value unchanged: no downstream notification) падает у большинства участников, которые вообще поддерживают или допускают выполнение побочных эффектов:
Абсолютно проваливают этот тест-кейс следующие библиотеки:
- @vue/reactivity
- @reactively/core
- solid-js
- pota
- anod
У этих движков, как и у @pravosleva/reactive-engine в контексте глобального ранера, промежуточное изменение сбивает внутренний контур отслеживания "чистого изменения" (net-zero change), приводя к ложному срабатыванию или затуханию последующих пуш-волн. Таким образом, падение в тесте #114 - это общий маркер для всех классических эффективных пуш-пулл (lazy) движков на рынке.
(см. src/core/core.effect.test.ts)
❌ Тест #180: Автоматический сброс корня через computed-цепочку
Что проверяет тест: Ситуацию, когда изменение базового сигнала запускает вычисление computed, а тот, в свою очередь, пытается сбросить этот же базовый сигнал обратно (например, для реализации циклического ограничителя ввода).
import { ReactiveEngine } from '@pravosleva/reactive-engine'
const engine = new ReactiveEngine()
const s = engine.signal(true, 'root_s')
// ❌ АНТИПАТТЕРН (воспроизводит падение теста #180)
const c = engine.computed(() => {
if (s.value === true) {
// ПОБОЧНАЯ ЗАПИСЬ: Сбрасываем корень обратно посреди pull-вычисления
s.value = false
}
return s.value
})
engine.effect(() => {
console.log(c.value) // Ошибка: гонка push/pull контуров, кэш 'c' застрянет в true
})
// ✅ КАК НАДО (Правильный обход паттерна #180)
// Логика циклического сброса и ограничений должна быть вынесена в эффект
const cClean = engine.computed(() => s.value)
engine.effect(() => {
if (cClean.value === true) {
// Безопасно сбрасываем сигнал на этапе выполнения очереди планировщика
s.value = false
}
})❌ Тест #186: Синхронная гонка каналов (атомарность побочного канала)
Что проверяет тест: Нарушение атомарности ("глюк" графа), когда один и тот же эффект одновременно подписан и на сам computed, и на сигнал побочного канала, в который этот computed скрытно записывает данные. Из-за гонки обновлений эффект видит промежуточное рассинхронизированное состояние.
import { ReactiveEngine } from '@pravosleva/reactive-engine'
const engine = new ReactiveEngine()
const src = engine.signal(0, 'src')
const sideMetric = engine.signal(10, 'sideMetric')
// ❌ АНТИПАТТЕРН (Воспроизводит падение теста #186)
const c = engine.computed(() => {
if (src.value === 1) {
// ПОБОЧНАЯ ЗАПИСЬ: Инкрементируем метрику внутри геттера
sideMetric.value = 55
}
return src.value * 2
})
// Подписываем эффект ОДНОВРЕМЕННО на два канала
engine.effect(() => {
// КРИТИЧЕСКИЙ СБОЙ: Из-за гонки push/pull эффект сработает дважды
// и на первом шаге увидит старое значение sideMetric (10) вместе с новым c (2)
console.log(`[Глюк] Вывод: c = ${c.value}, sideMetric = ${sideMetric.value}`)
})
src.value = 1 // Мутация корня запускает гонку обновлений
// ✅ КАК НАДО (Правильный обход паттерна #186)
// Разделяем потоки данных декларативным путем, сохраняя чистоту computed
const cClean = engine.computed(() => src.value * 2)
// Метрика вычисляется независимо, основываясь на корневом сигнале
const sideMetricClean = engine.computed(() => {
return src.value === 1 ? 55 : 10
})
engine.effect(() => {
// Теперь оба канала обновляются строго атомарно, в один синхронный тик!
console.log(`[Атомарно] Вывод: c = ${cClean.value}, sideMetric = ${sideMetricClean.value}`)
})