Перейти к основному содержимому

React Native

SDK MineFlow — platform-agnostic (ADR-0042): ядро (@mineflow/client-core) и хуки (@mineflow/client-react) одинаково работают в web и React Native. Всё, что зависит от платформы, ядро не знает и получает через инъектируемые порты:

ПортwebReact Native
tokenProviderKeycloakTokenProviderReactNativeTokenProvider (react-native-app-auth)
generateIdcrypto.randomUUIDreact-native-get-random-values / expo-crypto
fetchImplглобальный fetchпотоковый fetch (react-native-fetch-api / expo/fetch)

Хуки (useAssets, useCreateShiftReport, useNotificationFeed, …) переиспользуются как есть — TanStack Query работает в RN. UI пишется на RN-примитивах (View/Text/Pressable), бизнес-логика не дублируется: «share logic, not pixels».

к сведению

Этот рецепт — про сборку RN-провайдера из портов. Сами хуки описаны в гайде client-react; auth-адаптер — в auth-native. Полный список экспортов ядра — /api/client-core/.

1. tokenProvider — нативный OIDC

keycloak-js работает только в браузере, поэтому в RN логин идёт нативным OIDC-флоу (react-native-app-auth), а SDK получает токен через ReactNativeTokenProvider из @mineflow/auth-native. Адаптер не зависит от react-native-app-auth напрямую — приложение прокидывает текущее состояние (getState) и функцию refresh; так адаптер тестируем и не привязан к конкретной OIDC-библиотеке (можно expo-auth-session).

pnpm add @mineflow/auth-native
pnpm add react-native-app-auth
import { authorize, refresh } from 'react-native-app-auth';
import { ReactNativeTokenProvider } from '@mineflow/auth-native';

const config = {
issuer: 'https://auth.mineflow.local/realms/mineflow',
clientId: 'mineflow-mobile',
redirectUrl: 'com.mineflow://oauthredirect',
scopes: ['openid', 'profile', 'roles'],
};

// Состояние хранит приложение (результат authorize/refresh).
let authState = await authorize(config);

const tokenProvider = new ReactNativeTokenProvider({
getState: () => authState,
refresh: async () => {
authState = await refresh(config, { refreshToken: authState.refreshToken! });
return authState;
},
// minValiditySeconds?: 30 — запас до истечения для упреждающего refresh
});

ReactNativeTokenProvider реализует порт TokenProvider:

  • getToken({ forceRefresh }) — рефрешит токен, если он истекает в ближайшие minValiditySeconds (по умолчанию 30) или при forceRefresh (его реактивно выставляет fetch-слой при ответе 401). Если refresh упал — отдаёт текущий токен, API ответит 401, UI инициирует повторный login.
  • getRoles() — декодирует realm-роли из access-token (JWT) и маппит в канонические SystemRole (PascalCase). Используй для пропа roles провайдера.
Стабильность адаптера

tokenProvider и generateId провайдер сравнивает по ссылке — создавай ReactNativeTokenProvider один раз вне рендера (или в useState/useRef), иначе транспорт пересоберётся и SSE-фид переподключится при каждом ре-рендере.

Подробнее об опциях и NativeAuthStateauth-native и /api/auth-native/.

2. generateId — UUID для Idempotency-Key

Каждая write-операция несёт Idempotency-Key (ADR-0012); это свежий UUID на каждый вызов через порт generateId. В RN нет гарантированного crypto.randomUUID, поэтому его полифиллят.

pnpm add react-native-get-random-values
import 'react-native-get-random-values'; // импортируй в entrypoint, до всего остального

const generateId = () => crypto.randomUUID();
Expo: используй expo-crypto

react-native-get-random-values полифиллит только crypto.getRandomValues, но не crypto.randomUUID. На Expo (Hermes) полагаться на нативный crypto.randomUUID небезопасно — используй expo-crypto, где randomUUID() гарантирован:

import * as Crypto from 'expo-crypto';

const generateId = () => Crypto.randomUUID();

3. fetchImpl — потоковый fetch для SSE

Live-фид уведомлений (useNotificationFeed) читает text/event-stream из response.body (ReadableStream) аутентифицированным fetch — браузерный EventSource не умеет слать Authorization-заголовок (детали SSE). Дефолтный RN-fetch не отдаёт потоковый response.body, поэтому в MineflowProvider передают fetchImpl с потоковой поддержкой. Этот же fetch ядро использует и для REST, и для SSE — отдельной перепроводки на фичу не нужно.

  • Чистый RN: react-native-fetch-api.
  • Expo (Hermes): expo/fetch — потоковый из коробки.
pnpm add react-native-fetch-api # или ничего — expo/fetch уже в expo
import { fetch as expoFetch } from 'expo/fetch';

const transportFetch = expoFetch as unknown as typeof fetch;
примечание

Если SSE не нужен, fetchImpl можно не передавать — REST работает и на дефолтном RN-fetch. Поток нужен только для useNotificationFeed.

4. Сборка провайдера

Соберите три порта в один MineflowProvider. roles берётся из tokenProvider.getRoles() — провайдер сравнивает их по содержимому, поэтому inline-набор не вызывает лишних перестроений транспорта.

import { MineflowProvider } from '@mineflow/client-react';
import { useState, type ReactNode } from 'react';

export function AppProvider({ children }: { children: ReactNode }) {
// tokenProvider/generateId создаём один раз — сравниваются по ссылке.
const [provider] = useState(() => ({
tokenProvider,
generateId,
roles: tokenProvider.getRoles(),
}));

return (
<MineflowProvider
baseUrl={API_ORIGIN} // origin, без пути: эндпоинты уже содержат /api/v1
tokenProvider={provider.tokenProvider}
generateId={provider.generateId}
roles={provider.roles}
fetchImpl={transportFetch} // потоковый fetch для SSE
>
{children}
</MineflowProvider>
);
}

После этого все хуки работают как в web: чтение/мутации/саги/RBAC/FSM. Список — в гайде client-react.

5. Offline-gate — эталон оркестрации

Полевой пользователь работает без стабильной сети. Write-операции рапорта (ADR-0049) офлайн паузятся (не падают), очередь персистится и доигрывается строго по порядку при реконнекте/после рестарта. Механика — в объекте shiftReportDomain из @mineflow/client-react (подробнее — рецепт offline).

Эталон RN-оркестрации — apps/mobile/src/app/providers/offline-gate.tsx. Компонент рендерится внутри MineflowProvider, потому что ему нужны и QueryClient (useQueryClient), и API-клиент (useMineflowClient):

import { shiftReportDomain, useMineflowClient } from '@mineflow/client-react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
import { onlineManager, useQueryClient, type Mutation } from '@tanstack/react-query';
import {
persistQueryClientRestore,
persistQueryClientSave,
persistQueryClientSubscribe,
} from '@tanstack/react-query-persist-client';
import { useEffect, type ReactNode } from 'react';
import { AppState } from 'react-native';

// Персистим только paused (офлайн) и failed write'ы с mutationKey.
// Саги mutationKey не несут → исключены.
function shouldDehydrateMutation(m: Mutation): boolean {
return (m.state.isPaused || m.state.status === 'error') && Array.isArray(m.options.mutationKey);
}

export function OfflineGate({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
const client = useMineflowClient();

// (1) replay-defaults — нужен API-клиент; idempotent при повторе.
// Зовётся ДО restore/resume: восстановленные мутации знают, как себя проиграть.
useEffect(() => {
shiftReportDomain.registerDefaults(queryClient, client);
}, [queryClient, client]);

// (2) restore + persist + resume — ровно один раз (queryClient стабилен).
useEffect(() => {
const persister = createAsyncStoragePersister({ storage: AsyncStorage });
const opts = {
queryClient,
persister,
maxAge: Infinity, // write-outbox НЕ должен протухать (worker мог быть офлайн >24ч)
buster: 'v2',
dehydrateOptions: { shouldDehydrateMutation },
};

let active = true;
let unsubscribePersist = (): void => {};
void persistQueryClientRestore(opts).then(() => {
if (!active) return;
unsubscribePersist = persistQueryClientSubscribe(opts);
void queryClient.resumePausedMutations();
});

// При возврате связи — повторно пнуть очередь.
const unsubscribeOnline = onlineManager.subscribe(() => {
if (onlineManager.isOnline()) void queryClient.resumePausedMutations();
});

// Flush при уходе в фон — чтобы только что добавленный write пережил kill.
const appStateSub = AppState.addEventListener('change', (s) => {
if (s === 'background' || s === 'inactive') void persistQueryClientSave(opts);
});

return () => {
active = false;
unsubscribePersist();
unsubscribeOnline();
appStateSub.remove();
};
}, [queryClient]);

return <>{children}</>;
}
Два эффекта — намеренно

registerDefaults зависит от API-клиента (re-run безопасен, setMutationDefaults идемпотентен). А restore/subscribe/resume зависит только от стабильного queryClient и выполняется ровно один раз: смена identity клиента (например, резолв Keycloak discovery) не должна повторно гидрировать персистнутый outbox — у гидрации нет per-mutation дедупа, она бы задвоила каждый write.

Подключение TanStack onlineManager к сети

Без явной привязки RN onlineManager считает приложение всегда онлайн → офлайн-мутации падали бы вместо паузы. Привяжи его к состоянию сети устройства (один раз, до первой мутации):

import { onlineManager } from '@tanstack/react-query';
import * as Network from 'expo-network';

export function setupOnlineManager(): void {
onlineManager.setEventListener((setOnline) => {
void Network.getNetworkStateAsync()
.then((s) => setOnline(!!s.isConnected && s.isInternetReachable !== false))
.catch(() => setOnline(true));
const sub = Network.addNetworkStateListener((s) =>
setOnline(!!s.isConnected && s.isInternetReachable !== false),
);
return () => sub.remove();
});
}

6. UI на RN-примитивах

Хуки platform-agnostic, рендер — на твоих компонентах. Индикатор очереди (useIsOnline, shiftReportDomain.useOutbox, shiftReportDomain.useRetryFailed) на RN-примитивах:

import { shiftReportDomain, useIsOnline } from '@mineflow/client-react';
import { Pressable, Text, View } from 'react-native';

function OutboxBanner() {
const online = useIsOnline();
const { paused, pending, error } = shiftReportDomain.useOutbox();
const retry = shiftReportDomain.useRetryFailed(); // повтор упавших, тот же Idempotency-Key

if (online && paused + pending + error === 0) return null;

return (
<Pressable onPress={error > 0 ? retry : undefined}>
<View>
<Text>
{online ? 'Синхронизация…' : 'Офлайн'} · в очереди: {paused + pending}
{error > 0 ? ` · ошибок: ${error} (нажмите для повтора)` : ''}
</Text>
</View>
</Pressable>
);
}

Чтение списков и формы — те же хуки, что в web:

import { useShiftReports, useCreateShiftReport } from '@mineflow/client-react';
import { FlatList, Text } from 'react-native';

function ShiftReportsScreen() {
const { data, isLoading } = useShiftReports({ status: 'submitted', limit: 50 });
if (isLoading) return <Text>Загрузка…</Text>;
return (
<FlatList
data={data?.items ?? []}
keyExtractor={(r) => r.id}
renderItem={({ item }) => <Text>{item.shiftDate}</Text>}
/>
);
}

Что дальше

  • Хуки чтения/мутаций/саг — client-react
  • Auth-адаптер RN — auth-native
  • Offline-first запись и outbox — offline
  • Live SSE-фид — sse
  • Async-саги (202 + polling) — sagas
  • Обработка ошибок по codeerrors
  • RBAC-гейт и FSM-кнопки — rbac, fsm
  • REST-эндпоинты — /rest/