From global-plugin
Use when writing or reviewing React Native + Expo mobile code — screens, navigation, native module usage, OTA updates. Do NOT use for web React (use `frontend-implementation-guard`). Covers RN/Expo structure, navigation patterns, native module boundaries, EAS Build / EAS Update, platform-specific behaviour, offline UX.
npx claudepluginhub lgerard314/global-marketplace --plugin global-pluginThis skill uses the workspace's default tool permissions.
**Purpose:** Keep the mobile app's structure predictable, native boundaries thin, and updates safe on both stores.
Guides Next.js Cache Components and Partial Prerendering (PPR) with cacheComponents enabled. Implements 'use cache', cacheLife(), cacheTag(), revalidateTag(), static/dynamic optimization, and cache debugging.
Guides building MCP servers enabling LLMs to interact with external services via tools. Covers best practices, TypeScript/Node (MCP SDK), Python (FastMCP).
Share bugs, ideas, or general feedback.
Purpose: Keep the mobile app's structure predictable, native boundaries thin, and updates safe on both stores.
Use Expo managed workflow unless a specific native feature requires bare; document the reason if bare. Why: Managed workflow gives free OTA updates, predictable SDK upgrades, and no Xcode/Android Studio maintenance. Dropping to bare for a feature that could be handled by an Expo SDK package adds permanent maintenance burden and breaks OTA for native-touching changes.
Navigation (React Navigation v7) lives in a dedicated layer. Screens do not import each other directly — deep links and nav params only. Why: Direct screen imports create circular dependency risks, make deep linking impossible without refactoring, and couple unrelated feature modules. A centralised navigator tree keeps every route auditable in one place.
Native modules are wrapped by a single TypeScript adapter per module. Screens never call native APIs directly.
Why: Adapters are the only place where import { NativeModules } from 'react-native' or third-party native SDKs appear. This isolates platform surface area, enables mocking in tests, and means a native SDK swap touches one file not thirty screens.
Platform-specific branches (Platform.select, Platform.OS) are rare and centralised in adapters, not scattered in screens.
Why: Unconstrained Platform.OS checks in UI components fork the mental model of every screen into two parallel versions. Centralising them in adapters and style utilities keeps screen code platform-agnostic and readable.
Offline UX: every screen has a defined offline state — cached data shown, writes queued, a clear error message when neither is possible. Why: Mobile networks are unreliable. Silently failing or showing a blank screen erodes trust quickly. TanStack Query offline persistence (MMKV or AsyncStorage persister) handles read-side caching; a write queue with optimistic updates and reconciliation on reconnect handles mutations.
EAS Updates (OTA) respect store policy — JS-only changes only; any native code change requires a new EAS Build and store submission. Why: Pushing a native change via OTA violates Apple App Store and Google Play policies and will cause runtime crashes on devices that received the update but have the old binary. EAS Update should be gated by a runtime-version check that blocks incompatible updates.
Permissions prompts are requested at the moment of use, not on app start, and the UI explains why before the OS dialog appears. Why: iOS and Android users are far more likely to grant a permission when they understand the immediate context. An upfront multi-permission splash on first launch maximises denial rates and cannot be undone without the user going to Settings manually.
| Signal | Risk |
|---|---|
| Screen component imports another screen component | Nav graph collapses; deep linking breaks; circular deps likely |
Platform.OS checks inside JSX of a screen | Codebase silently forks; both paths need QA on every change |
OTA update that touches a native module or app.json native config | Store policy violation; runtime crash on mismatched runtime version |
Bad — camera called from two screens independently:
// screens/ProfileScreen.tsx
import * as ImagePicker from 'expo-image-picker';
const result = await ImagePicker.launchCameraAsync({ ... });
// screens/PostScreen.tsx
import * as ImagePicker from 'expo-image-picker';
const result = await ImagePicker.launchCameraAsync({ allowsEditing: true });
Good — single adapter, screens import the adapter:
// adapters/cameraAdapter.ts
import * as ImagePicker from 'expo-image-picker';
export async function capturePhoto(options?: ImagePicker.ImagePickerOptions) {
const permission = await ImagePicker.requestCameraPermissionsAsync();
if (!permission.granted) throw new Error('camera-denied');
return ImagePicker.launchCameraAsync({ quality: 0.8, ...options });
}
// screens/ProfileScreen.tsx
import { capturePhoto } from '@/adapters/cameraAdapter';
const result = await capturePhoto();
The adapter owns the permission request, default quality settings, and the single import of expo-image-picker. Swapping to a different image picker library touches one file.
Bad — request on mount, no context:
useEffect(() => {
Notifications.requestPermissionsAsync(); // cold launch, no explanation
}, []);
Good — request when the user triggers the feature, preceded by an explanation sheet:
async function enablePushNotifications() {
// Show your own explanation modal first
const confirmed = await showPermissionRationale({
title: 'Stay updated on your orders',
body: 'We send a notification when your order ships.',
});
if (!confirmed) return;
const { status } = await Notifications.requestPermissionsAsync();
if (status !== 'granted') {
// Guide user to Settings; do not re-prompt
await Linking.openSettings();
}
}
iOS never shows the OS dialog a second time if the user previously denied. The in-app rationale sheet is the only chance to set context before that permanent decision.
Bad — mutation fires immediately, fails silently offline:
const { mutate } = useMutation({ mutationFn: api.submitForm });
// If offline: unhandled network error, data lost
submitButton.onPress(() => mutate(formData));
Good — TanStack Query with offline mutation queue:
// query-client.ts
const queryClient = new QueryClient({
defaultOptions: {
mutations: {
networkMode: 'offlineFirst', // queue when offline, replay on reconnect
},
},
});
// In screen — optimistic update + queue
const { mutate, isPaused } = useMutation({
mutationFn: api.submitForm,
onMutate: async (variables) => {
// optimistic local update
queryClient.setQueryData(['draft'], variables);
},
onError: (_err, _vars, context) => {
queryClient.setQueryData(['draft'], context?.previous);
},
});
// UI communicates queued state
{isPaused && <OfflineBanner message="Will submit when back online" />}
Expo managed workflow is the default choice. The SDK (53+) bundles camera, notifications, location, secure store, and file system modules covering most production use cases.
Bare workflow is justified when the app requires a native SDK that has no Expo module equivalent, when a custom Gradle/Xcode build phase is mandatory, or when the team already owns iOS/Android engineers who maintain the native layer. When bare is chosen, document the decision in ARCHITECTURE.md.
Continuous Native Generation (CNG) — where android/ and ios/ directories are generated from config plugins and deleted from version control — is the recommended approach for new bare projects.
React Navigation v7 is the standard. The navigator tree lives in src/navigation/ and is the only place where <Stack.Navigator>, <Tab.Navigator>, and <Drawer.Navigator> appear. Screens are registered by name and receive typed params via the RootStackParamList type exported from src/navigation/types.ts.
Never import a screen component from another screen. Cross-screen transitions go through navigation.navigate('ScreenName', params) or navigation.push(...).
Typed navigation hooks (useNavigation<StackNavigationProp<RootStackParamList>>()) catch param mismatches at compile time. Every route that accepts params must have its type in RootStackParamList.
Modals and bottom sheets that are not full screens should not be registered as stack routes unless they need a URL. Use a local state flag or a lightweight modal manager instead.
For tab-level deep links (e.g., a push notification that opens a specific tab and then pushes a screen), use NavigationContainerRef combined with navigation.navigate from outside the component tree, not Linking.openURL with a custom scheme unless universal links are also set up.
Every third-party native SDK and every react-native core module that crosses into native land gets a TypeScript adapter file in src/adapters/. The adapter file:
src/adapters/__mocks__/ so unit tests never hit native code.Platform.select may appear inside adapters when the native API behaves differently per platform (e.g., Android requires WRITE_EXTERNAL_STORAGE for certain operations; iOS does not).
Use Platform.select({ ios: ..., android: ... }) inside adapters and style utilities. Never use it directly in JSX screen components.
For styles, centralise platform variants in a platformStyles.ts utility:
import { Platform, StyleSheet } from 'react-native';
export const shadow = Platform.select({
ios: { shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.15, shadowRadius: 4 },
android: { elevation: 4 },
});
For permissions, the native manifest entries must match code requests:
Info.plist keys (NSCameraUsageDescription, NSLocationWhenInUseUsageDescription, etc.) must be set via Expo config plugin or directly in the bare ios/ directory. Expo SDK 53 managed workflow sets these automatically when you add the relevant package and configure it in app.config.ts.AndroidManifest.xml permissions (CAMERA, ACCESS_FINE_LOCATION, POST_NOTIFICATIONS, etc.) must be listed. Android 13+ requires POST_NOTIFICATIONS to be requested at runtime; it is not granted automatically.Haptic feedback, share sheets, and status bar behaviour differ between platforms. Wrap each in an adapter.
Baseline offline UX:
@tanstack/query-async-storage-persister backed by MMKV or AsyncStorage) keeps the last successful response in local storage. Users see stale data, clearly labelled, rather than a blank screen.networkMode: 'offlineFirst'. TanStack Query queues them and replays on reconnect. The screen shows an isPaused indicator ("Will submit when online").@react-native-community/netinfo to detect connectivity. Show a persistent banner when offline; dismiss it automatically on reconnect.expo-secure-store (SecureStore) is for sensitive tokens and credentials — not for general offline data caching. For bulk offline data, use MMKV via react-native-mmkv or SQLite via expo-sqlite.
EAS Build compiles the native binary (.ipa / .aab). EAS Update pushes a new JS bundle and assets to devices that already have the binary installed.
The critical constraint: an EAS Update can only be applied to a binary that shares the same runtimeVersion. The runtime version must be bumped whenever:
app.config.ts change that modifies native code (permissions, entitlements, splash screen, icon).Set runtimeVersion policy to "appVersion" (ties it to the app.json version field) or "nativeVersion" (ties it to the native code hash). Do not use "exposdkVersion" in production — it updates too broadly.
Channels (production, staging, preview) map to EAS Update channels in eas.json. A staging binary should point to the staging channel; a production binary to production. Never point a production binary at staging.
JS-only changes (logic, copy, style, new React components without new native imports) are OTA-safe. Anything requiring a new native module is not OTA-safe and will crash old binaries.
The correct flow for any OS permission:
useEffect on mount.status === 'granted', proceed directly. If status === 'undetermined', show your rationale UI then request. If status === 'denied', guide to Settings via Linking.openSettings() — do not re-request.For expo-notifications, the POST_NOTIFICATIONS runtime permission on Android 13+ must be requested explicitly in code in addition to being declared in the manifest. Use the Notifications.requestPermissionsAsync() API from the adapter layer.
For location, prefer requestForegroundPermissionsAsync() unless background location is genuinely required by the product. Background location triggers additional App Store review scrutiny.
iOS info.plist usage description strings must be accurate and specific. These strings are set in app.config.ts via the ios.infoPlist field in managed workflow.
One line: GREEN / YELLOW / RED verdict on structural health — managed workflow discipline, navigation centralisation, adapter boundaries, offline UX, and OTA safety.
List each concern as file:line, severity, category, fix. Severity is blocker | major | minor. Category is one of workflow | navigation | adapter | platform | offline | eas-update | permissions. Fix is a one-line actionable remediation.
Example: src/screens/ProfileScreen.tsx:42, blocker, adapter, move expo-image-picker call into src/adapters/cameraAdapter.ts and import the adapter instead.
List every direct screen-to-screen import found. List every native API call that appears outside an adapter. These are blocking concerns.
Prefer Expo managed workflow over bare for apps without hard native dependencies — it preserves OTA for JS-only fixes and eliminates Xcode/Gradle maintenance. For bug fixes that do not touch native modules, prefer EAS Update over a binary-store release where acceptable, so users receive the fix within minutes instead of store-review days. If a native module seems required, check for an Expo SDK equivalent (camera, notifications, location, secure store, file system, sqlite, haptics, sharing) before ejecting to bare or adding a custom native module.
For each rule, mark PASS, CONCERN, or NOT APPLICABLE.
src/navigation/; no screen imports another screensrc/adapters/Platform.OS / Platform.select only appears in adapters and style utilitiesstate-integrity-check — offline sync conflict resolution logic.integration-contract-safety — API contract changes affecting mobile payloads.accessibility-guard — TalkBack / VoiceOver / dynamic type concerns.