Skip to content

Lifecycle host и remote

MF lifecycle имеет три уровня:

  1. root QueryRuntime живёт столько же, сколько host session;
  2. participant scope живёт столько же, сколько один mount remote;
  3. domain stores живут внутри remote mount.

Startup host

ts
const runtime = createQueryRuntime(...)

const persistence = await persistQueryRuntime({
  runtime,
  persister,
})

await runtime.initialize()

mountShellReactApp()

Persistence опционален. Если он включён, restore лучше выполнить до создания QueryStore remote.

Mount route

ts
const remote = await import("billing/bootstrap")

const scope = runtime.createParticipantScope(registration)

let unmountUi: (() => void) | null = null

try {
  unmountUi = remote.mount({
    container,
    api,
    scope,
  })
} catch (error) {
  scope.dispose()
  throw error
}

Registration проверяется до React mount. Если protocol/capabilities не совместимы, remote UI не появляется.

Что создаёт remote

В mount:

ts
const store = new BillingStore(scope, api)
const root = createRoot(container)
root.render(<BillingApp store={store} />)

Handlers, созданные через scope, отслеживаются и самим domain store, и scope. Локальный store.dispose() нужен для ясного ownership remote. scope.dispose() служит host boundary и освобождает всё оставшееся.

Unmount route

Правильный порядок:

ts
return () => {
  // 1. React перестаёт render и читать observable state.
  unmountUi?.()

  // 2. Scope освобождает handlers и owned requests.
  scope.dispose()
}

Почему UI первым: если сначала уничтожить scope, ещё смонтированный компонент может попытаться вызвать метод уже disposed store во время effect cleanup.

Повторный disposer

Сделайте composition disposer идемпотентным:

ts
let disposed = false

return () => {
  if (disposed) {
    return
  }

  disposed = true
  unmountUi()
  scope.dispose()
}

scope.dispose() сам безопасен при повторном вызове, но remote disposer может иметь собственные неидемпотентные ресурсы.

Смена route A → B

ts
let disposeCurrentRoute: (() => void) | null = null

export const activateRoute = async (
  mountNext: () => Promise<() => void>,
): Promise<void> => {
  disposeCurrentRoute?.()
  disposeCurrentRoute = null

  disposeCurrentRoute = await mountNext()
}

Не переиспользуйте disposed scope при возвращении на route. Создайте новый participant scope и новый remote mount.

Private и shared request при unmount

  • Direct request (scope.request) принадлежит participant и отменяется.
  • Private query observer отсоединяется; query GC следует gcTime.
  • Shared in-flight query не должна отменяться, если её ждёт другой participant.
  • Mutation/request owner context удаляется вместе со scope.

Именно поэтому remote вызывает scoped cancel()/dispose(), а не получает глобальный cancelQueries() root client.

Ошибка во время mount

Если remote создал store, но React render бросил ошибку, remote обязан освободить уже созданные ресурсы:

ts
export const mount = (options: BootstrapOptions): (() => void) => {
  const store = new RemoteStore(options.scope, options.api)
  const root = createRoot(options.container)

  try {
    root.render(<RemoteApp store={store} />)
  } catch (error) {
    store.dispose()
    root.unmount()
    throw error
  }

  return () => {
    root.unmount()
    store.dispose()
  }
}

Host catch дополнительно вызывает scope.dispose().

Logout

Logout меняет data partition. Старые scopes нельзя оставлять живыми.

ts
await routeManager.unmountAll()

for (const scope of activeScopes) {
  scope.dispose()
}

await persistence?.dispose()
await persister?.removeClient()
syncController?.dispose()

await runtime.dispose()

await sessionCoordinator.logout()

Точный порядок logout/session зависит от auth architecture. Инвариант: после завершения logout ни один remote не должен выполнить запрос со старым owner, token или user partition.

Shutdown host

При закрытии всего shell:

ts
disposeCurrentRoute?.()
syncController?.dispose()
await persistence?.dispose()
await runtime.dispose()
unmountShellReactApp()

Если SessionCoordinator был передан как внешний sessionCoordinator, runtime его не владеет. Если создан из sessionCoordinatorOptions, runtime освободит его сам.

Diagnostics после unmount

ts
const before = runtime.getDiagnostics()

disposeBillingRoute()

const after = runtime.getDiagnostics()

console.assert(
  after.activeParticipantCount === before.activeParticipantCount - 1,
)

В integration tests также проверяйте activeRequestCount, activeRequestAttemptCount, pendingRequestAttemptCount и отсутствие React/DOM listeners remote.