Тема
Lifecycle host и remote
MF lifecycle имеет три уровня:
- root
QueryRuntimeживёт столько же, сколько host session; - participant scope живёт столько же, сколько один mount remote;
- 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.