Skip to content

Связывание инстансов

📡 Способ 1. Синхронизация на уровне сигналов (JS-Level Signal Subscription)

Синхронизация независимых инстансов реактивного ядра посредством прямого вызова метода низкоуровневых подписок .subscribe() у сигналов или Proxy-объектов — это паттерн с максимальной изоляцией и околонулевым оверхедом (Zero-Overhead Infrastructure Binding). В этой модели мост синхронизации оперирует сырыми событиями изменений ({ from, to }), минуя планировщик фоновых эффектов (engine.effect) и не вовлекая глобальный указатель трекинга зависимостей (engine.activeEffect).

💡 Когда это уместно и необходимо использовать:

  • Изолированные SDK и библиотеки (Core Architecture): Идеально подходит при проектировании ядра крупных энтерпрайз-систем, где модули должны передавать примитивы между собой на чистом JavaScript, до маунта графической оболочки или вообще в условиях автономного серверного окружения (NodeJS / серверный рендеринг SSR).
  • Ультимативное модульное тестирование (Unit Testing): Поскольку связь прописана исключительно на уровне методов сигналов слоя данных (services.ts), она обладает абсолютной тестируемостью. Вы можете написать чистые тесты в Vitest, имитировать смену пользователя на Хосте и проверять реакцию Виджета без необходимости рендерить React-компоненты, подключать @testing-library/react или мокать хуки фреймворка.
  • Блокировка каскадного дребезга логов: Поскольку метод .subscribe() отрабатывает синхронно прямо в теле Proxy-сеттера, он не порождает дополнительных записей 🟩 EFFECT в системном шедулере REACTIVE TRANSACTION, сохраняя логгер транзакций кристально чистым и сфокусированным строго на интерфейсных событиях.

Почему в React.StrictMode может быть три подписчика?

subscribers: Array(3)
0: "react:use:host:active-user-id" // ← Боевой хук (Рендер №1)
1: "react:use:host:active-user-id" // ← Проверочный хук StrictMode (Рендер №2)
2: "host:bridge:ui-level-sync [IS_OPTIMIZED=1]" // ← Наш эффект-мост

🔬 Пошаговый рантайм-анализ (Способ 1: JS-Level Signal Subscription)

Введение мета-тега instanceName позволило изолированно верифицировать поведение независимых транзакционных очередей. Лог документирует пошаговый каскад реактивных событий:

  1. Инициализация и стартовый Push-мост (Size: 3 в widget): При инициализации модулей в слое данных срабатывает прямая JS-подписка .subscribe(). Первичное значение хоста ('user-1') мгновенно пробрасывается во второй движок. Изолированный widget-шедулер батчит три стартовых процесса: холодный расчет компута widget:computed:status, фиксацию входящего изменения сигнала (from: ''to: 'user-1') и перевод вычисляемой строки в стабильное состояние.
  2. Боевое переключение пользователя на Хосте (Size: 1 в host): При клике на кнопку «Выбрать User 2» сигнал host:active-user-id меняется (user-1user-2). В логе хоста батч имеет минимальный размер Size: 1 [INDEX]. Посмотрите на состав subscribers хост-сигнала: там находятся исключительно две копии 'react:use:host:active-user-id' (прямое следствие двойного рендеринга StrictMode на UI-слое). Служебного бэджа эффекта здесь больше нет! Нативная JS-подписка работает тише и не перегружает шедулер хоста лишними тасками профайлера.
  3. Реакция вечного JS-моста (Size: 2 в widget): В ту же миллисекунду, как только сеттер хост-сигнала завершает мутацию, коллбэк .subscribe() синхронно и напрямую меняет widgetLogic.currentTargetUser.value = 'user-2'. Второй суверенный шедулер (widget-инстанс) перехватывает этот пуш, создает независимую транзакцию Size: 2, обновляет локальный сигнал widget:target-user и пересчитывает компут статуса до актуального значения.
  4. Автономные клики плагина (Size: 2 в widget): При инкременте локального счетчика виджета транзакция полностью изолируется внутри example-117 (widget). Хост-система находится в абсолютном реактивном штиле, а ее логгер молчит.

🚨 Анализ утечек памяти (Memory Leaks Audit)

Один из самых критических вопросов при использовании низкоуровневых JS-подписок .subscribe() вне контекста фреймворка — это риск накопления «зомби-слушателей» в оперативной памяти.

В текущей архитектуре Example117 утечки памяти ПОЛНОСТЬЮ ОТСУТСТВУЮТ.

Архитектурное обоснование:

  1. Совпадение жизненных циклов (Identical Lifecycles): И hostEngine, и widgetEngine (а также их синглтон-сервисы HostGlobalService и WidgetInternalService) создаются как глобальные константы на уровне всего слоя данных (Core Data Layer) приложения. Они рождаются один раз при старте вкладки браузера и должны жить вечно.
  2. Отсутствие UI-привязки: Так как подписка .subscribe() оформлена в файле service.Example117.ts, она физически не зависит от того, сколько раз React-компонент Example117 будет монтироваться, размонтироваться или перерисовываться. При уходе пользователя с этой страницы или очистке инпута файлов, вечный JS-мост продолжает стабильно удерживать одну-единственную ссылку в памяти.
  3. Безопасность React-слоя: Очистка useEffect больше не требуется, так как сам React-компонент не порождает динамических кросс-движковых связей при маунте.

⚠️ Важное предупреждение для разработчиков:

Данный способ идеален для статических, фундаментальных связей между подсистемами. Однако, если бы инстанс widgetEngine создавался динамически внутри функции (например, при каждом клике на кнопку генерировался бы новый кастомный калькулятор), то вызов hostLogic.activeUserId.subscribe(...) создал бы классическую утечку памяти, удерживая ссылку на уничтоженный виджет в недрах hostEngine. Для таких динамических сценариев следует использовать исключительно Способ 3 (useEffect cleanup) или сохранять возвращаемую функцию отписки CleanupFn для принудительного вызова деструктора!

🛡️ Есть ли здесь утечка памяти (Memory Leak)?

Нет, здесь всё абсолютно стерильно.

При переходе на Способ 1 (чистый JS-мост через .subscribe()), который мы зафиксировали в качестве финального для Example117, или в продакшн-сборке приложения, картина стабилизируется. Поскольку в Способе 1 подписка осуществляется напрямую на уровне JS-файла, минуя useEffect и engine.effect, а StrictMode не дублирует внешние модули, количество подписчиков на боевом клике вернётся к идеальному академическому значению 2 :

  1. "react:use:host:active-user-id" (Один честный UI-кадр фреймворка).
  2. Низкоуровневый системный коллбэк, который не выводится бэджем эффекта, так как работает напрямую через сеттеры. Этот лог подтверждает, что ядро фреймворка безупречно и честно фиксирует каждое прикосновение к реактивным сигналам со стороны React-компонентов.

Пример 117

Инициализация инстансов:

ts
import { AbstractService } from '@pravosleva/reactive-engine'
import { ReactiveEngine } from '@pravosleva/reactive-engine/react'

// 1. Инициализируем два абсолютно независимых инстанса реактивного ядра из ветки /react
export const hostEngine = new ReactiveEngine({
  logger: {
    isEnabled: false,
    traceTime: false,
    filter: /^host:.*/,
    instanceName: 'example-117 (host)',
  }
})

export const widgetEngine = new ReactiveEngine({
  logger: {
    isEnabled: false,
    traceTime: false,
    filter: /^widget:.*/,
    instanceName: 'example-117 (widget)',
  }
})

// 2. Описываем Глобальный сервис хост-приложения (Engine 1)
export class HostGlobalService extends AbstractService {
  public activeUserId = this.engine.signal<string>('user-1', 'host:active-user-id')

  public switchUser(id: string) {
    this.activeUserId.value = id
  }
}

// 3. Описываем Внутренний сервис изолированного виджета (Engine 2)
export class WidgetInternalService extends AbstractService {
  public currentTargetUser = this.engine.signal<string>('', 'widget:target-user')
  public widgetLocalCounter = this.engine.signal<number>(0, 'widget:local-counter')

  // Вычисляемое свойство виджета
  public widgetStatus = this.engine.computed(() => {
    return `[Виджет v117 для ${this.currentTargetUser.value}]. Локальных кликов: ${this.widgetLocalCounter.value}`
  }, 'widget:computed:status')

  public incLocal() {
    this.widgetLocalCounter.value += 1
  }
}

// 4. Регистрируем синглтоны в их родных контейнерах через inject
export const hostLogic = hostEngine.inject(HostGlobalService)
export const widgetLogic = widgetEngine.inject(WidgetInternalService)

// ЧИСТЫЙ JS-МОСТ СИНХРОНИЗАЦИИ (Способ 1):
// Мы подписываемся напрямую на мутации сигнала Первого движка (Engine 1).
// Метод .subscribe() у сигналов ядра поставляет напрямую новое значение (строку).
hostLogic.activeUserId.subscribe((nextUserId) => {
  // Напрямую и синхронно пушем новое строковое значение во Второй движок
  widgetLogic.currentTargetUser.value = nextUserId
})

React-компонент:

tsx
import baseClasses from '~/ui.common.module.scss'
import btnClasses from '~/ui.button.module.scss'
import { hostEngine, widgetEngine, hostLogic, widgetLogic } from './service.Example117'
import clsx from 'clsx'

export const Example117 = () => {
  // Пассивные подписки на слои отображения
  const activeUserId = hostEngine.use(hostLogic.activeUserId)
  const widgetStatus = widgetEngine.use(widgetLogic.widgetStatus)

  return (
    <div className={clsx(baseClasses.unit, baseClasses.stack2)} style={{ width: '600px', display: 'flex', flexDirection: 'column', gap: '16px', fontFamily: 'system-ui' }}>
      <div className={baseClasses.absoluteUnitLabel}>Engine instances sample</div>

      {/* СЛОЙ 1: ХОСТ ПРИЛОЖЕНИЕ (ДВИЖЕК 1) */}
      <div
        className={baseClasses.stack1}
        style={{ padding: '16px', background: '#1a1a24', borderRadius: '16px' }}
      >
        <h4 style={{ color: '#00b4d8' }}>🌐 Хост-приложение (Engine #1)</h4>
        <div style={{ fontSize: 'small', fontFamily: 'monospace', background: '#000', padding: '8px', borderRadius: '6px', marginBottom: '10px', color: '#ccc' }}>
          Текущий пользователь в Системе: <b>{activeUserId}</b>
        </div>

        <div style={{ display: 'flex', gap: '8px' }}>
          <button
            onClick={() => hostLogic.switchUser('user-1')}
            className={clsx(
              btnClasses.btn,
              btnClasses.neonBtn,
              btnClasses['neonBtn--primary'],
              {
                [btnClasses['neonBtn--contained']]: activeUserId === 'user-1',
                [btnClasses['neonBtn--outlined']]: activeUserId !== 'user-1'
              }
            )}
          >
            User 1
          </button>
          <button
            onClick={() => hostLogic.switchUser('user-2')}
            className={clsx(
              btnClasses.btn,
              btnClasses.neonBtn,
              btnClasses['neonBtn--primary'],
              {
                [btnClasses['neonBtn--contained']]: activeUserId === 'user-2',
                [btnClasses['neonBtn--outlined']]: activeUserId !== 'user-2'
              }
            )}
          >
            User 2
          </button>
        </div>
      </div>

      {/* СЛОЙ 2: ИЗОЛИРОВАННЫЙ ВИДЖЕТ (ДВИЖЕК 2) */}
      <div
        className={baseClasses.stack1}
        style={{ padding: '16px', background: '#111116', borderRadius: '16px' }}
      >
        <h4 style={{ color: '#42b883' }}>🧩 Изолированный Плагин-Виджет (Engine #2)</h4>
        <div style={{ fontSize: 'small', fontFamily: 'monospace', background: '#000', padding: '8px', borderRadius: '6px', marginBottom: '10px', color: '#ccc' }}>
          {widgetStatus}
        </div>

        <button
          onClick={() => widgetLogic.incLocal()}
          className={clsx(btnClasses.btn, btnClasses.neonBtn, btnClasses['neonBtn--secondary'], btnClasses['neonBtn--contained'])}
        >
          💥 Локальный клик виджета (+1)
        </button>
      </div>

    </div>
  )
}