diff --git a/README.md b/README.md index 7de7c3c..c06028e 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Development skills for AI coding agents. Plug into your favorite AI coding tool | `android-native-dev` | Android native application development with Material Design 3. Kotlin / Jetpack Compose, adaptive layouts, Gradle configuration, accessibility (WCAG), build troubleshooting, performance optimization, and motion system. | Official | | `ios-application-dev` | iOS application development guide covering UIKit, SnapKit, and SwiftUI. Touch targets, safe areas, navigation patterns, Dynamic Type, Dark Mode, accessibility, collection views, and Apple HIG compliance. | Official | | `flutter-dev` | Flutter cross-platform development covering widget patterns, Riverpod/Bloc state management, GoRouter navigation, performance optimization, and testing strategies. | Official | +| `react-native-dev` | React Native and Expo development guide covering components, styling, animations, navigation, state management, forms, networking, performance optimization, testing, native capabilities, and engineering (project structure, deployment, SDK upgrades, CI/CD). | Official | | `shader-dev` | Comprehensive GLSL shader techniques for creating stunning visual effects — ray marching, SDF modeling, fluid simulation, particle systems, procedural generation, lighting, post-processing, and more. ShaderToy-compatible. | Official | | `gif-sticker-maker` | Convert photos (people, pets, objects, logos) into 4 animated GIF stickers with captions. Funko Pop / Pop Mart style, powered by MiniMax Image & Video Generation API. | Official | | `minimax-pdf` | Generate, fill, and reformat PDF documents with a token-based design system. CREATE polished PDFs from scratch (15 cover styles), FILL existing form fields, or REFORMAT documents into a new design. Print-ready output with typography and color derived from document type. | Official | diff --git a/README_zh.md b/README_zh.md index ec224bb..1f6e096 100644 --- a/README_zh.md +++ b/README_zh.md @@ -15,6 +15,7 @@ | `android-native-dev` | 基于 Material Design 3 的 Android 原生应用开发。Kotlin / Jetpack Compose、自适应布局、Gradle 配置、无障碍(WCAG)、构建问题排查、性能优化与动效系统。 | Official | | `ios-application-dev` | iOS 应用开发指南,涵盖 UIKit、SnapKit 和 SwiftUI。触控目标、安全区域、导航模式、Dynamic Type、深色模式、无障碍、集合视图,符合 Apple HIG 规范。 | Official | | `flutter-dev` | Flutter 跨平台开发指南,涵盖 Widget 模式、Riverpod/Bloc 状态管理、GoRouter 导航、性能优化与测试策略。 | Official | +| `react-native-dev` | React Native 与 Expo 开发指南,涵盖组件、样式、动画、导航、状态管理、表单、网络请求、性能优化、测试、原生能力及工程化(项目结构、部署、SDK 升级、CI/CD)。 | Official | | `shader-dev` | 全面的 GLSL 着色器技术,用于创建惊艳的视觉效果 — 光线行进、SDF 建模、流体模拟、粒子系统、程序化生成、光照、后处理等。兼容 ShaderToy。 | Official | | `gif-sticker-maker` | 将照片(人物、宠物、物品、Logo)转换为 4 张带字幕的动画 GIF 贴纸。Funko Pop / Pop Mart 盲盒风格,基于 MiniMax 图片与视频生成 API。 | Official | | `minimax-pdf` | 基于 token 化设计系统生成、填写和重排 PDF 文档。支持三种模式:CREATE(从零生成,15 种封面风格)、FILL(填写现有表单字段)、REFORMAT(将已有文档重排为新设计)。排版与配色由文档类型自动推导,输出即可打印。 | Official | diff --git a/skills/react-native-dev/SKILL.md b/skills/react-native-dev/SKILL.md new file mode 100644 index 0000000..96456ae --- /dev/null +++ b/skills/react-native-dev/SKILL.md @@ -0,0 +1,149 @@ +--- +name: react-native-dev +description: | + React Native and Expo development guide covering components, styling, animations, navigation, + state management, forms, networking, performance optimization, testing, native capabilities, + and engineering (project structure, deployment, SDK upgrades, CI/CD). + Use when: building React Native or Expo apps, implementing animations or native UI, managing + state, fetching data, writing tests, optimizing performance, deploying to App Store/Play Store, + setting up CI/CD, upgrading Expo SDK, or configuring Tailwind/NativeWind. +license: MIT +metadata: + version: "1.0.0" + category: mobile + sources: + - Expo documentation (docs.expo.dev) + - React Native documentation (reactnative.dev) + - EAS (Expo Application Services) documentation +--- + +# React Native & Expo Development Guide + +A practical guide for building production-ready React Native and Expo applications. Covers UI, animations, state, testing, performance, and deployment. + +## References + +Consult these resources as needed: + +- [references/navigation.md](references/navigation.md) — Expo Router: Stack, Tabs, NativeTabs (`headerLargeTitle`, `headerBackButtonDisplayMode`), links, modals, sheets, context menus +- [references/components.md](references/components.md) — FlashList patterns, `expo-image`, safe areas (`contentInsetAdjustmentBehavior`), native controls, blur/glass effects, storage +- [references/styling.md](references/styling.md) — StyleSheet, NativeWind/Tailwind, platform styles, theming, dark mode +- [references/animations.md](references/animations.md) — Reanimated 3: entering/exiting, shared values, gestures, scroll-driven +- [references/state-management.md](references/state-management.md) — Zustand (selectors, persist), Jotai (atoms, derived), React Query, Context +- [references/forms.md](references/forms.md) — React Hook Form + Zod: validation, multi-step, dynamic arrays +- [references/networking.md](references/networking.md) — fetch wrapper, React Query (optimistic updates), auth tokens, offline, API routes, webhooks +- [references/performance.md](references/performance.md) — Profiling workflow, FlashList + `memo`, bundle analysis, TTI, memory leaks, animation perf +- [references/testing.md](references/testing.md) — Jest, React Native Testing Library, E2E with Maestro +- [references/native-capabilities.md](references/native-capabilities.md) — Camera, location, permissions (`use*Permissions` hooks), haptics, notifications, biometrics +- [references/engineering.md](references/engineering.md) — Project layout (`components/ui/`, `stores/`, `services/`), path aliases, SDK upgrades, EAS build/submit, CI/CD, DOM components + +## Quick Reference + +### Component Preferences + +| Purpose | Use | Instead of | +|---------|-----|------------| +| Lists | `FlashList` (`@shopify/flash-list`) + `memo` items | `FlatList` (no view recycling) | +| Images | `expo-image` | RN `` (no cache, no WebP) | +| Press | `Pressable` | `TouchableOpacity` (legacy) | +| Audio | `expo-audio` | `expo-av` (deprecated) | +| Video | `expo-video` | `expo-av` (deprecated) | +| Animations | Reanimated 3 | RN Animated API (limited) | +| Gestures | Gesture Handler | PanResponder (legacy) | +| Platform check | `process.env.EXPO_OS` | `Platform.OS` | +| Context | `React.use()` | `React.useContext()` (React 18) | +| Safe area scroll | `contentInsetAdjustmentBehavior="automatic"` | `` | +| SF Symbols | `expo-image` with `source="sf:name"` | `expo-symbols` | + +### Scaling Up + +| Situation | Consider | +|-----------|----------| +| Long lists with scroll jank | Virtualized list libraries (e.g. FlashList) | +| Want Tailwind-style classes | NativeWind v4 | +| High-frequency storage reads | Sync-based storage (e.g. MMKV) | +| New project with Expo | Expo Router over bare React Navigation | + +### State Management + +| State Type | Solution | +|------------|----------| +| Local UI state | `useState` / `useReducer` | +| Shared app state | Zustand or Jotai | +| Server / async data | React Query | +| Form state | React Hook Form + Zod | + +### Performance Priorities + +| Priority | Issue | Fix | +|----------|-------|-----| +| CRITICAL | Long list jank | `FlashList` + memoized items | +| CRITICAL | Large bundle | Avoid barrel imports, enable R8 | +| HIGH | Too many re-renders | Zustand selectors, React Compiler | +| HIGH | Slow startup | Disable bundle compression, native nav | +| MEDIUM | Animation drops | Only animate `transform`/`opacity` | + +## New Project Init + +```bash +# 1. Create project +npx create-expo-app@latest my-app --template blank-typescript +cd my-app + +# 2. Install Expo Router + core deps +npx expo install expo-router react-native-safe-area-context react-native-screens + +# 3. (Optional) Common extras +npx expo install expo-image react-native-reanimated react-native-gesture-handler +``` + +Then configure: + +1. Set entry point in `package.json`: `"main": "expo-router/entry"` +2. Add scheme in `app.json`: `"scheme": "my-app"` +3. Delete `App.tsx` and `index.ts` +4. Create `app/_layout.tsx` as root Stack layout +5. Create `app/(tabs)/_layout.tsx` for tab navigation +6. Create route files in `app/(tabs)/` (see [navigation.md](references/navigation.md)) + +For web support, also install: `npx expo install react-native-web react-dom @expo/metro-runtime` + +## Core Principles + +**Consult references before writing**: when implementing navigation, lists, networking, or project setup, read the matching reference file above for patterns and pitfalls. + +**Try Expo Go first** (`npx expo start`). Custom builds (`eas build`) only needed when using local Expo modules, Apple targets, or third-party native modules not in Expo Go. + +**Conditional rendering**: use `{count > 0 && }` not `{count && }` (renders "0"). + +**Animation rule**: only animate `transform` and `opacity` — GPU-composited, no layout thrash. + +**Imports**: always import directly from source, not barrel files — avoids bundle bloat. + +**Lists and images**: before using `FlatList` or RN `Image`, check the Component Preferences table above — `FlashList` and `expo-image` are almost always the right choice. + +**Route files**: always use kebab-case, never co-locate components/types/utils in `app/`. + +## Checklist + +### New Project Setup +- [ ] `tsconfig.json` path aliases configured +- [ ] `EXPO_PUBLIC_API_URL` env var set per environment +- [ ] Root layout has `GestureHandlerRootView` (if using gestures) +- [ ] `contentInsetAdjustmentBehavior="automatic"` on all scroll views +- [ ] `FlashList` instead of `FlatList` for lists > 20 items + +### Before Shipping +- [ ] Profile in `--profile` mode, fix frames > 16ms +- [ ] Bundle analyzed (`source-map-explorer`), no barrel imports +- [ ] R8 enabled for Android +- [ ] Unit + component tests for critical paths +- [ ] E2E flows for login, core feature, checkout + +--- + +Flutter development → see `flutter-dev` skill. +iOS native (UIKit/SwiftUI) → see `ios-application-dev` skill. +Android native (Kotlin/Compose) → see `android-native-dev` skill. + +*React Native is a trademark of Meta Platforms, Inc. Expo is a trademark of 650 Industries, Inc. All other product names are trademarks of their respective owners.* diff --git a/skills/react-native-dev/references/animations.md b/skills/react-native-dev/references/animations.md new file mode 100644 index 0000000..f552bd4 --- /dev/null +++ b/skills/react-native-dev/references/animations.md @@ -0,0 +1,254 @@ +# Animations Reference + +Reanimated 3 animations, gestures, and transitions for Expo/React Native. + +## Core Rules + +- **Only animate `transform` and `opacity`** — GPU-composited, no layout recalculation +- Use `useDerivedValue` for computed animated values, not inline JS expressions +- Use `Gesture.Tap` instead of `Pressable` inside `GestureDetector` +- All Reanimated callbacks run as worklets on the UI thread — no async/await + +## Setup + +```bash +npx expo install react-native-reanimated react-native-gesture-handler +``` + +```js +// babel.config.js +module.exports = { presets: ["babel-preset-expo"], plugins: ["react-native-reanimated/plugin"] }; +``` + +```tsx +// app/_layout.tsx — wrap root in GestureHandlerRootView +import { GestureHandlerRootView } from "react-native-gesture-handler"; +export default function RootLayout() { + return ; +} +``` + +## Entering / Exiting Animations + +```tsx +import Animated, { + FadeIn, FadeOut, + SlideInRight, SlideOutLeft, + ZoomIn, ZoomOut, + BounceIn, +} from "react-native-reanimated"; + +// Basic + + Content + + +// With options + + +// Spring-based + +``` + +### Built-in Presets + +| Category | Entering | Exiting | +|----------|----------|---------| +| Fade | `FadeIn`, `FadeInUp`, `FadeInDown`, `FadeInLeft`, `FadeInRight` | `FadeOut*` | +| Slide | `SlideInUp`, `SlideInDown`, `SlideInLeft`, `SlideInRight` | `SlideOut*` | +| Zoom | `ZoomIn`, `ZoomInUp`, `ZoomInDown` | `ZoomOut*` | +| Bounce | `BounceIn`, `BounceInUp`, `BounceInDown` | `BounceOut*` | +| Flip | `FlipInXUp`, `FlipInYLeft` | `FlipOut*` | +| Roll | `RollInLeft`, `RollInRight` | `RollOut*` | +| Stretch | `StretchInX`, `StretchInY` | `StretchOut*` | +| Pinwheel | `PinwheelIn` | `PinwheelOut` | +| Rotate | `RotateInDownLeft` | `RotateOut*` | +| LightSpeed | `LightSpeedInLeft` | `LightSpeedOut*` | + +## Shared Values & useAnimatedStyle + +```tsx +import { + useSharedValue, + useAnimatedStyle, + withSpring, + withTiming, + withRepeat, + withSequence, + Easing, +} from "react-native-reanimated"; + +const offset = useSharedValue(0); +const opacity = useSharedValue(1); + +const animStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: offset.value }], + opacity: opacity.value, +})); + +// Animate +offset.value = withSpring(100); +opacity.value = withTiming(0, { duration: 300, easing: Easing.out(Easing.quad) }); + +// Repeat +opacity.value = withRepeat(withTiming(0.3, { duration: 800 }), -1, true); + +// Sequence +offset.value = withSequence( + withTiming(-10, { duration: 100 }), + withTiming(10, { duration: 100 }), + withSpring(0), +); + + +``` + +## useDerivedValue + +```tsx +import { useDerivedValue } from "react-native-reanimated"; + +const progress = useSharedValue(0); // 0–1 +const rotation = useDerivedValue(() => `${progress.value * 360}deg`); +const scale = useDerivedValue(() => 0.5 + progress.value * 0.5); + +const animStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: rotation.value }, { scale: scale.value }], +})); +``` + +## Layout Animations + +```tsx +import { Layout, LinearTransition, CurvedTransition } from "react-native-reanimated"; + +// Item reorder/add/remove animation + + {/* Content that changes size/position */} + + +// Spring layout transition + +``` + +## Gestures + +```tsx +import { Gesture, GestureDetector } from "react-native-gesture-handler"; +import { useSharedValue, useAnimatedStyle, withSpring } from "react-native-reanimated"; + +// Pan gesture +const offsetX = useSharedValue(0); +const offsetY = useSharedValue(0); + +const panGesture = Gesture.Pan() + .onUpdate((e) => { + offsetX.value = e.translationX; + offsetY.value = e.translationY; + }) + .onEnd(() => { + offsetX.value = withSpring(0); + offsetY.value = withSpring(0); + }); + +const animStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: offsetX.value }, { translateY: offsetY.value }], +})); + + + + +``` + +```tsx +// Tap gesture (use instead of Pressable inside GestureDetector) +const tapGesture = Gesture.Tap() + .numberOfTaps(1) + .onEnd(() => { + scale.value = withSequence(withTiming(0.95), withSpring(1)); + }); + +// Pinch gesture +const baseScale = useSharedValue(1); +const savedScale = useSharedValue(1); + +const pinchGesture = Gesture.Pinch() + .onUpdate((e) => { baseScale.value = savedScale.value * e.scale; }) + .onEnd(() => { savedScale.value = baseScale.value; }); + +// Composed gestures +const composed = Gesture.Simultaneous(panGesture, pinchGesture); +const exclusive = Gesture.Exclusive(tapGesture, panGesture); +``` + +## Scroll-Driven Animations + +```tsx +import Animated, { + useAnimatedScrollHandler, + useSharedValue, + interpolate, + Extrapolation, +} from "react-native-reanimated"; + +const scrollY = useSharedValue(0); +const scrollHandler = useAnimatedScrollHandler((e) => { + scrollY.value = e.contentOffset.y; +}); + +// Parallax header +const headerStyle = useAnimatedStyle(() => ({ + transform: [{ + translateY: interpolate(scrollY.value, [0, 200], [0, -100], Extrapolation.CLAMP), + }], + opacity: interpolate(scrollY.value, [0, 200], [1, 0], Extrapolation.CLAMP), +})); + + + + Parallax Header + + +``` + +## Zoom Transitions (Expo Router, iOS 18+) + +```tsx +import { Link } from "expo-router"; + + + + + + + + +``` + +## Adding Animations to State Changes + +```tsx +// ✓ Always add entering/exiting for state-driven UI changes +{isVisible && ( + + + +)} + +// ✓ AnimatedFlatList for list item changes +import Animated from "react-native-reanimated"; +const AnimatedFlashList = Animated.createAnimatedComponent(FlashList); +``` + +## Common Mistakes + +| Wrong | Right | +|-------|-------| +| Animate `width`/`height` | Animate `transform: scaleX/scaleY` | +| Inline JS math in `useAnimatedStyle` | `useDerivedValue` for computations | +| `Pressable` inside `GestureDetector` | `Gesture.Tap()` | +| `async` in worklet | Run async outside, update sharedValue in callback | +| Frequent `console.log` in worklet | `console.log` works but serializes to JS thread — use sparingly in hot paths | diff --git a/skills/react-native-dev/references/components.md b/skills/react-native-dev/references/components.md new file mode 100644 index 0000000..df8aaea --- /dev/null +++ b/skills/react-native-dev/references/components.md @@ -0,0 +1,124 @@ +# Components Reference + +Native UI components, media, visual effects, and storage patterns for Expo/React Native. + +## Images + +```tsx +import { Image } from "expo-image"; + +// Always use expo-image — not React Native's built-in Image + +``` + +## Lists + +```tsx +import { FlashList } from "@shopify/flash-list"; +import { memo } from "react"; + +const Item = memo(({ title }: { title: string }) => ( + {title} +)); + + } + keyExtractor={(item) => item.id} + estimatedItemSize={80} +/> +``` + +## Safe Areas + +```tsx +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +// With ScrollView + + {/* content */} + + +// Manual insets +const insets = useSafeAreaInsets(); + +``` + +## Native Controls (iOS) + +```tsx +import { Switch } from "react-native"; +import SegmentedControl from "@react-native-segmented-control/segmented-control"; + +// Switch + + +// Segmented Control + setSelectedIndex(e.nativeEvent.selectedSegmentIndex)} +/> +``` + +## Form Sheets (Bottom Sheet) + +```tsx +// app/modal.tsx +import { Stack } from "expo-router"; + +``` + +## Visual Effects + +```tsx +import { BlurView } from "expo-blur"; + + +// Liquid glass (iOS 26+, New Architecture only) +import { GlassEffect } from "expo-glass-effect"; + +``` + +## Search + +```tsx +// Using expo-router search bar (iOS only) +import { useNavigation } from "expo-router"; + +useEffect(() => { + navigation.setOptions({ + headerSearchBarOptions: { + placeholder: "Search...", + onChangeText: (e) => setQuery(e.nativeEvent.text), + }, + }); +}, [navigation]); +``` + +## Storage + +| Need | Solution | +|------|----------| +| Structured data | `expo-sqlite` | +| Simple key-value | `@react-native-async-storage/async-storage` | +| Sensitive data | `expo-secure-store` | + +## Media + +```tsx +import { CameraView, useCameraPermissions } from "expo-camera"; +import { useAudioPlayer } from "expo-audio"; +import { useVideoPlayer, VideoView } from "expo-video"; +import * as ImagePicker from "expo-image-picker"; +``` diff --git a/skills/react-native-dev/references/engineering.md b/skills/react-native-dev/references/engineering.md new file mode 100644 index 0000000..560c351 --- /dev/null +++ b/skills/react-native-dev/references/engineering.md @@ -0,0 +1,527 @@ +# Engineering Reference + +Project structure, tooling, builds, releases, and platform integration for Expo / React Native. + +## Project Structure + +### Standard Layout + +``` +my-app/ + app/ File-based routing (Expo Router) + _layout.tsx Root layout: providers, fonts, NativeTabs + index.tsx → / + (tabs)/ + _layout.tsx Tab navigator + home.tsx → /home + profile.tsx → /profile + (auth)/ + login.tsx → /login (group, not in URL) + register.tsx → /register + user/ + [id].tsx → /user/:id + [id]/ + posts.tsx → /user/:id/posts + api/ + users+api.ts → /api/users (server route) + users/[id]+api.ts → /api/users/:id + components/ Reusable UI components + ui/ Primitive components (Button, Input, Card) + shared/ Composed components (UserAvatar, PostCard) + hooks/ Custom React hooks + stores/ Zustand / Jotai stores + services/ API client, external service wrappers + utils/ Pure utility functions + constants/ App-wide constants (colors, spacing, config) + types/ Shared TypeScript types/interfaces + assets/ Static assets (images, fonts, icons) + scripts/ Build/dev helper scripts + app.json Expo config + eas.json EAS Build config + tsconfig.json TypeScript config with path aliases + .env Environment variables + .env.development + .env.production +``` + +### Route Conventions + +| File | Route | Notes | +|------|-------|-------| +| `app/index.tsx` | `/` | Home/root | +| `app/about.tsx` | `/about` | Static route | +| `app/user/[id].tsx` | `/user/:id` | Dynamic segment | +| `app/user/[...rest].tsx` | `/user/*` | Catch-all | +| `app/(tabs)/home.tsx` | `/home` | Group (not in URL) | +| `app/(a,b)/shared.tsx` | Shared between tabs `a` and `b` | Multi-group | +| `app/_layout.tsx` | Layout wrapper | No route | +| `app/+not-found.tsx` | 404 page | | +| `app/api/users+api.ts` | `/api/users` | Server route | + +**Rules**: +- Routes only in `app/` — no components, types, or utils +- Always have a route matching `/` +- Use kebab-case filenames (`user-profile.tsx`, not `UserProfile.tsx`) +- Remove old route files when restructuring + +### Path Aliases + +```json +// tsconfig.json +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "paths": { + "@/*": ["./*"], + "@components/*": ["./components/*"], + "@hooks/*": ["./hooks/*"], + "@stores/*": ["./stores/*"], + "@services/*": ["./services/*"], + "@utils/*": ["./utils/*"], + "@constants/*": ["./constants/*"], + "@types/*": ["./types/*"] + } + } +} +``` + +```tsx +// ✗ Relative imports — fragile, change with file moves +import { Button } from "../../../components/ui/Button"; + +// ✓ Alias imports — stable +import { Button } from "@components/ui/Button"; +``` + +Metro resolves `paths` and `baseUrl` from `tsconfig.json` natively — no extra config needed. If using a non-Metro bundler, install `babel-plugin-module-resolver`: + +```js +// babel.config.js — only needed for non-Metro bundlers +module.exports = { + presets: ["babel-preset-expo"], + plugins: [ + ["module-resolver", { + root: ["./"], + alias: { + "@": "./", + "@components": "./components", + "@hooks": "./hooks", + "@stores": "./stores", + "@services": "./services", + }, + }], + ], +}; +``` + +### Components Organization + +``` +components/ + ui/ Atomic components + Button.tsx + Input.tsx + Card.tsx + Badge.tsx + index.ts Barrel export + shared/ Composed components + UserAvatar.tsx + PostCard.tsx + EmptyState.tsx + layout/ Layout components + Screen.tsx SafeArea wrapper + Header.tsx +``` + +```tsx +// components/ui/index.ts — barrel export +export { Button } from "./Button"; +export { Input } from "./Input"; +export { Card } from "./Card"; + +// Usage +import { Button, Input, Card } from "@components/ui"; +``` + +### Design System + +``` +constants/ + colors.ts Color palette + semantic colors + spacing.ts 8pt grid spacing values + typography.ts Font families, sizes, weights + theme.ts Combined theme object +``` + +```tsx +// constants/colors.ts +export const colors = { + primary: "#6200EE", + secondary: "#03DAC6", + background: "#FFFFFF", + surface: "#F5F5F5", + error: "#B00020", + text: { primary: "#000000DE", secondary: "#0000008A" }, +} as const; + +// constants/spacing.ts — 8pt grid +export const spacing = { + xs: 4, sm: 8, md: 16, lg: 24, xl: 32, xxl: 48, +} as const; + +// constants/typography.ts +export const typography = { + sizes: { xs: 12, sm: 14, md: 16, lg: 20, xl: 24, xxl: 32 }, + weights: { regular: "400", medium: "500", semibold: "600", bold: "700" }, +} as const; +``` + +### Services Layer + +``` +services/ + api/ + client.ts Base fetch client with auth headers + users.ts User-related API calls + posts.ts Post-related API calls + storage/ + secure-store.ts Wrapper for expo-secure-store + async-storage.ts Wrapper for AsyncStorage + notifications/ + push.ts Expo push notification helpers +``` + +```tsx +// services/api/client.ts +const BASE_URL = process.env.EXPO_PUBLIC_API_URL!; + +export const api = { + get: (path: string, token?: string) => + fetch(`${BASE_URL}${path}`, { + headers: { Authorization: token ? `Bearer ${token}` : "" }, + }).then(async (r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json() as Promise; + }), + // post/put/delete follow same pattern — add method, Content-Type, JSON.stringify(body) +}; +``` + +### Monorepo + +``` +my-monorepo/ + apps/ + mobile/ Expo app (all native deps here) + package.json + app.json + web/ Next.js app + package.json + packages/ + ui/ Shared UI components (no native deps) + package.json + utils/ Shared utilities (no native deps) + package.json + types/ Shared TypeScript types + package.json + package.json Root workspace config +``` + +```json +// Root package.json +{ + "private": true, + "workspaces": ["apps/*", "packages/*"], + "scripts": { + "mobile": "yarn workspace @my/mobile start", + "web": "yarn workspace @my/web dev" + } +} +``` + +**Monorepo rules**: +- **Keep native dependencies in the app package** (`apps/mobile`) — never in shared packages +- Use a single version of each dependency across all packages +- Shared packages should be pure JS/TS only + +### Environment Variables + +```bash +# .env (committed, non-sensitive defaults) +EXPO_PUBLIC_APP_NAME=MyApp +EXPO_PUBLIC_API_VERSION=v1 + +# .env.development (local only, gitignored) +EXPO_PUBLIC_API_URL=http://localhost:3000 + +# .env.production (CI/CD only, gitignored) +EXPO_PUBLIC_API_URL=https://api.production.example.com +``` + +```tsx +// types/env.d.ts +declare global { + namespace NodeJS { + interface ProcessEnv { + EXPO_PUBLIC_API_URL: string; + EXPO_PUBLIC_APP_NAME: string; + } + } +} +export {}; +``` + +### Custom Fonts + +```bash +npx expo install expo-font +``` + +```json +// app.json — config plugin (preferred over manual linking) +{ + "expo": { + "plugins": [ + ["expo-font", { "fonts": ["./assets/fonts/Inter-Regular.ttf"] }] + ] + } +} +``` + +```tsx +// app/_layout.tsx +import { useFonts } from "expo-font"; +import { SplashScreen } from "expo-router"; + +SplashScreen.preventAutoHideAsync(); + +export default function RootLayout() { + const [loaded] = useFonts({ "Inter-Regular": require("../assets/fonts/Inter-Regular.ttf") }); + useEffect(() => { if (loaded) SplashScreen.hideAsync(); }, [loaded]); + if (!loaded) return null; + return ; +} +``` + +## Development Builds + +Expo Go (`npx expo start`) covers most use cases out of the box. Switch to a custom dev client when your project uses native code that Expo Go doesn't bundle — for example, a local Expo module in `modules/`, an Apple target (widget, app clip), or a community native library that isn't pre-installed in Expo Go. + +### Creating a Dev Client + +```bash +# Option A — cloud build, push to TestFlight / internal distribution +eas build -p ios --profile development --submit + +# Option B — build locally (requires Xcode / Android Studio) +eas build -p ios --profile development --local +``` + +After installing on the device or simulator, connect with: + +```bash +npx expo start --dev-client +``` + +### eas.json Profile + +```json +{ + "build": { + "development": { + "developmentClient": true, + "distribution": "internal", + "autoIncrement": true + } + } +} +``` + +## Upgrading the SDK + +### Routine Upgrade + +```bash +npx expo install expo@latest --fix # bumps Expo + aligns peer deps +npx expo-doctor # surfaces remaining mismatches +``` + +Then test on both platforms and rebuild the dev client if you use one. + +### Trying a Pre-release + +Pre-release versions are tagged `@next` on npm: + +```bash +npx expo install expo@next --fix +``` + +### Notable Changes Across SDK Versions + +| Version | What Changed | +|---------|-------------| +| SDK 53 | New Architecture on by default; Expo Go requires it; `autoprefixer` no longer needed | +| SDK 54 | React 19 (`use()` replaces `useContext`, `` replaces ``, `forwardRef` removed); React Compiler available; `EXPO_USE_FAST_RESOLVER` removed | +| SDK 55 | NativeTabs API updated — Icon/Label/Badge accessed via `NativeTabs.Trigger.*` | +| Ongoing | `expo-av` deprecated in favor of `expo-audio` + `expo-video` | + +### React 19 Patterns (SDK 54+) + +```tsx +// Context +import { use, createContext } from "react"; +const ThemeCtx = createContext("light"); +// consume: const theme = use(ThemeCtx); +// provide: ... + +// Refs — no more forwardRef +function Field({ ref, ...props }: Props & { ref?: React.Ref }) { + return ; +} +``` + +### Opting Out of New Architecture + +If a third-party library breaks under the New Architecture: + +```json +{ "expo": { "newArchEnabled": false } } +``` + +Check compatibility at [reactnative.directory](https://reactnative.directory). + +## Releasing + +### Build Profiles + +A typical `eas.json` has three tiers: + +```json +{ + "cli": { "version": ">= 16.0.1", "appVersionSource": "remote" }, + "build": { + "development": { "developmentClient": true, "distribution": "internal", "autoIncrement": true }, + "preview": { "distribution": "internal", "autoIncrement": true }, + "production": { "autoIncrement": true, "ios": { "resourceClass": "m-medium" } } + } +} +``` + +### Building & Submitting + +```bash +# Build for both platforms +eas build -p ios --profile production +eas build -p android --profile production + +# Build + submit in one step +eas build -p ios --profile production --submit + +# Or submit a finished build separately +eas submit -p ios +eas submit -p android +``` + +### Store Submission Notes + +**iOS** — Run `eas credentials` to set up signing. Create the app record in App Store Connect, fill metadata, then `--submit` pushes the build to TestFlight automatically. + +**Android** — Create a Google Play service account, download its JSON key, and reference it in `eas.json` under `submit.production.android.serviceAccountKeyPath`. The first build must be uploaded manually through Play Console; subsequent builds use `eas submit`. + +### Over-the-Air Updates + +For JS-only changes (no new native code), skip the full build/review cycle: + +```bash +npx expo install expo-updates +eas update --branch production --message "Fix checkout rounding error" +``` + +### Web Hosting + +```bash +npx expo export -p web +eas deploy # preview URL +eas deploy --prod # production +``` + +## CI/CD with EAS Workflows + +Workflow files live in `.eas/workflows/` and follow a YAML schema: + +```yaml +# .eas/workflows/release.yml +name: Release to stores + +on: + push: + branches: [main] + +jobs: + build: + type: build + params: + platform: all + profile: production + + submit: + type: submit + needs: [build] + params: + platform: all + profile: production +``` + +```yaml +# .eas/workflows/pr-check.yml +name: PR check + +on: + pull_request: + branches: [main] + +jobs: + preview-build: + type: build + params: + platform: all + profile: preview +``` + +## DOM Components + +The `"use dom"` directive lets you render web-only code inside a WebView on native while running it as standard DOM on web. Useful for libraries that depend on browser APIs (chart libraries, rich text editors, syntax highlighters). + +```tsx +// components/RichPreview.tsx +"use dom"; + +import ReactMarkdown from "react-markdown"; + +export default function RichPreview({ markdown }: { markdown: string }) { + return {markdown}; +} +``` + +```tsx +// app/note/[id].tsx — native screen +import RichPreview from "@/components/RichPreview"; + +export default function NoteScreen() { + const { content } = useNote(); + return ( + + + + ); +} +``` + +Rules: +- `"use dom"` must be the first statement in the file +- One default export per file; cannot be mixed with native components +- Props must be serializable (strings, numbers, booleans, plain objects/arrays) +- Async function props bridge native actions into the webview (e.g., `onSave: (data) => Promise`) +- Cannot be used in `_layout.tsx` files +- Router hooks that read native navigation state (`useLocalSearchParams`, `usePathname`, etc.) must be called in the native parent and passed as props diff --git a/skills/react-native-dev/references/forms.md b/skills/react-native-dev/references/forms.md new file mode 100644 index 0000000..5fb782e --- /dev/null +++ b/skills/react-native-dev/references/forms.md @@ -0,0 +1,300 @@ +# Forms Reference + +React Hook Form + Zod validation for React Native / Expo. + +## Setup + +```bash +npx expo install react-hook-form zod @hookform/resolvers +``` + +## Basic Form + +```tsx +import { useForm, Controller } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; + +const schema = z.object({ + email: z.string().email("Invalid email"), + password: z.string().min(8, "Min 8 characters"), +}); +type FormData = z.infer; + +export function LoginForm({ onSubmit }: { onSubmit: (data: FormData) => void }) { + const { + control, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { email: "", password: "" }, + }); + + return ( + + {/* Controller pattern — repeat for each field */} + ( + + )} + /> + {errors.email && {errors.email.message}} + + {/* Same Controller pattern for password, with secureTextEntry */} + + + {isSubmitting ? "Submitting..." : "Login"} + + + ); +} +``` + +## Zod Schema Patterns + +```tsx +import { z } from "zod"; + +// Registration form +const registerSchema = z.object({ + name: z.string().min(2, "Name must be at least 2 characters").max(50), + email: z.string().email("Invalid email address"), + password: z.string() + .min(8, "At least 8 characters") + .regex(/[A-Z]/, "Must contain uppercase letter") + .regex(/[0-9]/, "Must contain a number"), + confirmPassword: z.string(), + age: z.number({ invalid_type_error: "Age must be a number" }).int().min(18, "Must be 18+").optional(), + role: z.enum(["admin", "user", "guest"]), + agreedToTerms: z.literal(true, { errorMap: () => ({ message: "Must agree to terms" }) }), +}).refine((data) => data.password === data.confirmPassword, { + message: "Passwords do not match", + path: ["confirmPassword"], +}); + +// All-optional schema — use .optional() or .partial() +const profileSchema = registerSchema.pick({ name: true, email: true }).partial(); + +// Nested objects — compose schemas with z.array() and references +const addressSchema = z.object({ street: z.string().min(1), city: z.string().min(1), country: z.string().length(2) }); +const orderSchema = z.object({ items: z.array(z.object({ productId: z.string(), quantity: z.number().int().positive() })).min(1), shippingAddress: addressSchema }); +``` + +## Form State + +```tsx +const { + control, + handleSubmit, + watch, + setValue, + getValues, + reset, + setError, + clearErrors, + formState: { + errors, + isSubmitting, + isValid, + isDirty, // Any field changed from defaultValues + dirtyFields, // Which fields changed + touchedFields, // Which fields were focused + }, +} = useForm({ resolver: zodResolver(schema) }); + +// Watch a field value +const password = watch("password"); +const allValues = watch(); // Watch all + +// Set a value programmatically +setValue("email", "prefilled@example.com", { shouldValidate: true }); + +// Reset form +reset(); // Back to defaultValues +reset({ email: "new@email.com" }); // Reset with new values + +// Set server-side errors +setError("email", { message: "Email already in use" }); +``` + +## Async Submit with Error Handling + +```tsx +const { handleSubmit, setError } = useForm(); + +const onSubmit = async (data: FormData) => { + try { + await api.post("/auth/register", data); + router.replace("/home"); + } catch (error) { + if (error instanceof ApiError && error.status === 409) { + setError("email", { message: "Email already registered" }); + } else { + setError("root", { message: "Something went wrong. Please try again." }); + } + } +}; + +// Display root error +{errors.root && {errors.root.message}} +``` + +## Multi-Step Forms + +```tsx +const schema = z.object({ + step1: z.object({ name: z.string().min(1), email: z.string().email() }), + step2: z.object({ phone: z.string(), address: z.string() }), + step3: z.object({ password: z.string().min(8), confirmPassword: z.string() }), +}); + +type FormData = z.infer; + +export function MultiStepForm() { + const [step, setStep] = useState(1); + const { control, handleSubmit, trigger, formState: { errors } } = useForm({ + resolver: zodResolver(schema), + }); + + const nextStep = async () => { + const stepKey = `step${step}` as keyof FormData; + const valid = await trigger(stepKey); // Validate only current step's fields + if (valid) setStep(s => s + 1); + }; + + // Render step component by index, with Back/Next/Submit navigation + // Key pattern: trigger(stepKey) validates only current step before advancing + return (/* StepOne | StepTwo | StepThree + Back/Next/Submit buttons */); + +} +``` + +## Reusable Field Components + +```tsx +// components/ui/FormField.tsx +import { Controller, Control, FieldValues, Path } from "react-hook-form"; + +interface FormFieldProps { + control: Control; + name: Path; + label: string; + placeholder?: string; + secureTextEntry?: boolean; + keyboardType?: TextInputProps["keyboardType"]; +} + +export function FormField({ + control, name, label, placeholder, secureTextEntry, keyboardType, +}: FormFieldProps) { + // Wraps Controller with: label, styled TextInput, and error message display + // Uses fieldState.error for per-field error, accessibilityLabel for a11y + return ( + ( + + {label} + + {error && {error.message}} + + )} + /> + ); +} + +// Usage + + +``` + +## Dynamic Arrays + +```tsx +import { useFieldArray } from "react-hook-form"; + +const schema = z.object({ + tags: z.array(z.object({ value: z.string().min(1) })).min(1, "Add at least one tag"), +}); + +function TagsForm() { + const { control, handleSubmit } = useForm>(); + const { fields, append, remove } = useFieldArray({ control, name: "tags" }); + + return ( + + {fields.map((field, index) => ( + + ( + + )} + /> + remove(index)}> + + ))} + append({ value: "" })}>+ Add Tag + + ); +} +``` + +## Keyboard Handling + +```tsx +import { KeyboardAvoidingView, Platform, ScrollView } from "react-native"; + +export function FormScreen() { + return ( + + + + + + ); +} +``` + +## Testing Forms + +```tsx +import { render, fireEvent, waitFor, screen } from "@testing-library/react-native"; +import { userEvent } from "@testing-library/react-native"; + +it("validates required fields", async () => { + render(); + fireEvent.press(screen.getByText("Login")); // Submit without filling + + await waitFor(() => { + expect(screen.getByText("Invalid email")).toBeTruthy(); + expect(screen.getByText("Min 8 characters")).toBeTruthy(); + }); +}); + +it("submits with valid data", async () => { + const onSubmit = jest.fn(); + const user = userEvent.setup(); + render(); + + await user.type(screen.getByPlaceholderText("Email"), "user@example.com"); + await user.type(screen.getByPlaceholderText("Password"), "password123"); + await user.press(screen.getByText("Login")); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith({ email: "user@example.com", password: "password123" }); + }); +}); +``` diff --git a/skills/react-native-dev/references/native-capabilities.md b/skills/react-native-dev/references/native-capabilities.md new file mode 100644 index 0000000..4f235e1 --- /dev/null +++ b/skills/react-native-dev/references/native-capabilities.md @@ -0,0 +1,163 @@ +# Native Capabilities Reference + +Camera, location, permissions, haptics, notifications, and biometrics for Expo/React Native. + +## Permissions + +All Expo modules that need permissions expose a `use*Permissions()` hook. Follow this pattern: + +1. Call the permission hook to get current status and a request function +2. Check `status` — if not `granted`, show a rationale and call `requestPermission()` +3. If the user denies twice, `canAskAgain` becomes `false` — direct them to Settings + +```tsx +import { useCameraPermissions } from "expo-camera"; + +const [permission, requestPermission] = useCameraPermissions(); + +if (!permission?.granted) { + // Show rationale, then call requestPermission() +} +``` + +| Module | Permission Hook | +|--------|----------------| +| `expo-camera` | `useCameraPermissions()` | +| `expo-location` | `useForegroundPermissions()` / `useBackgroundPermissions()` | +| `expo-media-library` | `usePermissions()` | +| `expo-notifications` | `getPermissionsAsync()` / `requestPermissionsAsync()` | +| `expo-contacts` | `usePermissions()` | + +For modules without a hook, use `requestPermissionsAsync()` / `getPermissionsAsync()` directly. + +## Camera + +```tsx +import { CameraView, useCameraPermissions } from "expo-camera"; + +const [permission, requestPermission] = useCameraPermissions(); +const cameraRef = useRef(null); + +// Capture a photo +const photo = await cameraRef.current?.takePictureAsync(); + +// Toggle front/back +const [facing, setFacing] = useState<"front" | "back">("back"); +``` + +For simple photo/video selection without a camera UI, use `expo-image-picker`: + +```tsx +import * as ImagePicker from "expo-image-picker"; + +const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ["images"], + allowsEditing: true, + quality: 0.8, +}); +``` + +## Location + +```tsx +import * as Location from "expo-location"; + +// One-time location +const { status } = await Location.requestForegroundPermissionsAsync(); +if (status === "granted") { + const location = await Location.getCurrentPositionAsync({}); + // location.coords.latitude, location.coords.longitude +} +``` + +For background location tracking, request `requestBackgroundPermissionsAsync()` and register a background task. Background location requires the `location` background mode in `app.json`: + +```json +{ + "expo": { + "ios": { "infoPlist": { "UIBackgroundModes": ["location"] } } + } +} +``` + +## Haptics + +```tsx +import * as Haptics from "expo-haptics"; + +// Light tap feedback (button press) +Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + +// Success / error / warning +Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + +// Selection change (picker scroll) +Haptics.selectionAsync(); +``` + +| Style | When to Use | +|-------|-------------| +| `ImpactFeedbackStyle.Light` | Button taps, toggles | +| `ImpactFeedbackStyle.Medium` | Drag snaps, significant actions | +| `ImpactFeedbackStyle.Heavy` | Destructive actions, impacts | +| `NotificationFeedbackType.Success` | Task completed | +| `NotificationFeedbackType.Warning` | Attention needed | +| `NotificationFeedbackType.Error` | Action failed | +| `selectionAsync()` | Picker/slider value changes | + +## Notifications + +### Push Notifications (Expo) + +```tsx +import * as Notifications from "expo-notifications"; +import * as Device from "expo-device"; + +async function registerForPushNotifications() { + if (!Device.isDevice) return; // Push doesn't work on simulators + + const { status } = await Notifications.requestPermissionsAsync(); + if (status !== "granted") return; + + const token = await Notifications.getExpoPushTokenAsync({ + projectId: "your-project-id", // From app.json > extra > eas > projectId + }); + // Send token.data to your server +} +``` + +### Notification Handlers + +```tsx +Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowAlert: true, + shouldPlaySound: true, + shouldSetBadge: true, + }), +}); + +// Listen for received/tapped notifications +const subscription = Notifications.addNotificationReceivedListener(notification => { + // Notification received while app is foregrounded +}); +``` + +## Biometrics + +```tsx +import * as LocalAuthentication from "expo-local-authentication"; + +const hasHardware = await LocalAuthentication.hasHardwareAsync(); +const isEnrolled = await LocalAuthentication.isEnrolledAsync(); + +if (hasHardware && isEnrolled) { + const result = await LocalAuthentication.authenticateAsync({ + promptMessage: "Authenticate to continue", + fallbackLabel: "Use passcode", + }); + if (result.success) { + // Authenticated + } +} +``` diff --git a/skills/react-native-dev/references/navigation.md b/skills/react-native-dev/references/navigation.md new file mode 100644 index 0000000..2136886 --- /dev/null +++ b/skills/react-native-dev/references/navigation.md @@ -0,0 +1,271 @@ +# Navigation Reference + +Expo Router file-based navigation: Stack, Tabs, modals, links, and context menus. + +## File Conventions + +``` +app/ + _layout.tsx Root layout (providers, NativeTabs) + index.tsx → / + about.tsx → /about + user/ + [id].tsx → /user/:id + [id]/ + posts.tsx → /user/:id/posts + (tabs)/ + _layout.tsx Tab navigator (group, not in URL) + home.tsx → /home + profile.tsx → /profile + (index,search)/ + _layout.tsx Shared Stack for both tabs + index.tsx → / + search.tsx → /search + i/[id].tsx → /i/:id (shared detail screen) + api/ + users+api.ts → /api/users (server route) +``` + +**Rules**: +- Routes live only in `app/` — never co-locate components, types, or utils there +- Always have a route matching `/` (may be inside a group) +- Remove old route files when restructuring navigation +- Use kebab-case filenames + +## Root Layout (Stack) + +```tsx +// app/_layout.tsx — root is always a Stack +import { Stack } from "expo-router"; + +export default function RootLayout() { + return ( + + + + + ); +} +``` + +**Always set page title via `Stack.Screen options.title`**, never use a custom Text element as a title. + +## Tabs — Which to Use + +| Scenario | Use | +|----------|-----| +| Custom design system, cross-platform | **JS Tabs** (stable, fully customizable) | +| iOS-native look, Liquid Glass (iOS 26+) | **NativeTabs** (alpha, limited customization) | + +## JS Tabs + +```tsx +// app/(tabs)/_layout.tsx +import { Tabs } from "expo-router"; +import { Ionicons } from "@expo/vector-icons"; + +export default function TabLayout() { + return ( + + , + }} + /> + + ); +} +``` + +## NativeTabs (alpha, iOS 18+) + +> Alpha API — all tabs render at once, limited customization, max 5 tabs on Android. Use when you want native iOS look (Liquid Glass, native blur/transitions) without rebuilding it yourself. + +```tsx +import { NativeTabs } from "expo-router/unstable-native-tabs"; + +export default function Layout() { + return ( + + + + Home + + + + Profile + + + ); +} +``` + +## Shared Stack for Multiple Tabs + +```tsx +// app/(index,search)/_layout.tsx — shared Stack for both index and search tabs +import { Stack } from "expo-router/stack"; + +const tabLabels: Record = { index: "Home", search: "Explore" }; + +export default function Layout({ segment }: { segment: string }) { + const activeTab = segment.replace(/[()]/g, ""); + + return ( + + + + + ); +} +``` + +## Link Component + +```tsx +import { Link } from "expo-router"; + +// Basic navigation +About + +// Dynamic routes +Profile + +// Wrapping custom component + + Settings + +``` + +## Programmatic Navigation + +```tsx +import { useRouter, useLocalSearchParams } from "expo-router"; + +const router = useRouter(); +router.push("/settings"); +router.replace("/login"); // No back button +router.back(); + +// Access route params +const { id } = useLocalSearchParams<{ id: string }>(); +``` + +## Modals & Sheets + +```tsx +// Modal presentation + + +// Form sheet with detents + +``` + +## Context Menus on Links + +```tsx + + + + + + + + + {}} /> + + + +``` + +## Link Previews (iOS only, requires Expo SDK 54+) + +```tsx + + + + + {/* Shows peek preview on 3D touch / long press */} + +``` + +## Header Search Bar + +```tsx +// In Stack.Screen — preferred over building custom search UI + setQuery(e.nativeEvent.text), + onCancelButtonPress: () => setQuery(""), + }, + }} +/> +``` + +## Deep Linking + +```json +// app.json +{ + "expo": { + "scheme": "myapp", + "ios": { + "associatedDomains": ["applinks:myapp.example.com"] + }, + "android": { + "intentFilters": [ + { + "action": "VIEW", + "autoVerify": true, + "data": [{ "scheme": "https", "host": "myapp.example.com" }], + "category": ["BROWSABLE", "DEFAULT"] + } + ] + } + } +} +``` + +Expo Router handles deep links automatically — `/user/123` maps to `app/user/[id].tsx`. + +## ScrollView in Routes + +When a route belongs to a Stack, its first child should almost always be a ScrollView: + +```tsx +export default function HomeScreen() { + return ( + + {/* Content */} + + ); +} +``` + +Use `contentInsetAdjustmentBehavior="automatic"` on `ScrollView`, `FlatList`, and `SectionList` — this handles safe areas and header insets automatically. Prefer it over ``. diff --git a/skills/react-native-dev/references/networking.md b/skills/react-native-dev/references/networking.md new file mode 100644 index 0000000..831d6eb --- /dev/null +++ b/skills/react-native-dev/references/networking.md @@ -0,0 +1,346 @@ +# Networking Reference + +Building a robust data layer for Expo apps: API clients, server state, authentication, and server-side routes. + +## API Client + +### Setup + +Create a thin wrapper around `fetch` (or `expo/fetch` on SDK 53+) rather than installing axios. Build a generic `request(path, init?)` function that: + +- Prepends `process.env.EXPO_PUBLIC_API_URL` to the path +- Defaults `Content-Type: application/json`, merges caller headers +- On `!res.ok`, throws an error with `status` and `body` attached (use `Object.assign`) so callers can branch on HTTP status +- Returns `res.json() as Promise` + +Then export convenience methods: `api.get(path)`, `api.post(path, body)`, etc., each delegating to `request()` with the appropriate method and `JSON.stringify(body)`. + +### Typed Errors + +Distinguish network-level failures (no connectivity, DNS) from HTTP-level errors (4xx/5xx). The wrapper above attaches `status` and `body` to thrown errors so callers can branch: + +```tsx +try { + await api.post("/tasks", newTask); +} catch (err: any) { + if (err.status === 409) { + Alert.alert("Duplicate", "A task with that title already exists."); + } else if (err.status === undefined) { + Alert.alert("Offline", "Check your connection and try again."); + } +} +``` + +## Server State (React Query) + +### Provider + +```tsx +// app/_layout.tsx +import { QueryClientProvider, QueryClient } from "@tanstack/react-query"; + +const qc = new QueryClient({ + defaultOptions: { queries: { staleTime: 60_000 } }, +}); + +export default function RootLayout() { + return ( + + + + ); +} +``` + +### Reading Data + +```tsx +function TaskList({ projectId }: { projectId: string }) { + const { data: tasks, isPending, error } = useQuery({ + queryKey: ["projects", projectId, "tasks"], + queryFn: () => api.get(`/projects/${projectId}/tasks`), + }); + + if (isPending) return ; + if (error) return ; + + return ( + } + estimatedItemSize={56} + /> + ); +} +``` + +### Writing Data + +```tsx +function useCompleteTask(projectId: string) { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: (taskId: string) => api.put(`/tasks/${taskId}`, { done: true }), + onSuccess: () => qc.invalidateQueries({ queryKey: ["projects", projectId, "tasks"] }), + }); +} +``` + +### Optimistic Updates + +For snappy UIs, update the cache before the server confirms: + +```tsx +const toggle = useMutation({ + mutationFn: (task: Task) => api.put(`/tasks/${task.id}`, { done: !task.done }), + onMutate: async (task) => { + await qc.cancelQueries({ queryKey }); + const prev = qc.getQueryData(queryKey); + qc.setQueryData(queryKey, (old) => + old?.map((t) => (t.id === task.id ? { ...t, done: !t.done } : t)), + ); + return { prev }; + }, + onError: (_err, _task, ctx) => qc.setQueryData(queryKey, ctx?.prev), + onSettled: () => qc.invalidateQueries({ queryKey }), +}); +``` + +## Authentication + +### Storing Credentials + +Use `expo-secure-store` for any token or secret. AsyncStorage is unencrypted and readable on rooted/jailbroken devices. + +```tsx +import * as SecureStore from "expo-secure-store"; + +const TOKEN_KEY = "session_token"; + +export async function saveToken(token: string) { + await SecureStore.setItemAsync(TOKEN_KEY, token); +} +export async function getToken() { + return SecureStore.getItemAsync(TOKEN_KEY); +} +export async function clearToken() { + await SecureStore.deleteItemAsync(TOKEN_KEY); +} +``` + +### Injecting Auth Headers + +Extend the API client to attach the token automatically: + +```tsx +export async function authRequest(path: string, init?: RequestInit): Promise { + const token = await getToken(); + return request(path, { + ...init, + headers: { ...init?.headers, ...(token && { Authorization: `Bearer ${token}` }) }, + }); +} +``` + +### Refreshing Expired Tokens + +Avoid stampeding refresh calls when multiple requests discover the token is expired simultaneously. Hold a single in-flight refresh promise and let all waiters share it: + +```tsx +let pending: Promise | null = null; + +async function getFreshToken(): Promise { + if (pending) return pending; + + pending = (async () => { + const refresh = await SecureStore.getItemAsync("refresh_token"); + const { accessToken } = await api.post<{ accessToken: string }>("/auth/refresh", { refresh }); + await saveToken(accessToken); + return accessToken; + })(); + + try { + return await pending; + } finally { + pending = null; + } +} +``` + +## Environment Variables + +```bash +# .env.development +EXPO_PUBLIC_API_URL=http://localhost:3000 + +# .env.production +EXPO_PUBLIC_API_URL=https://api.production.example.com +``` + +- The `EXPO_PUBLIC_` prefix makes a variable available in client JS (inlined at build time) +- Variables **without** the prefix are only accessible in server-side API routes +- Never expose database credentials or write-capable API keys via `EXPO_PUBLIC_` +- Restart the dev server after editing `.env` files + +Type the variables for autocomplete: + +```tsx +// env.d.ts +declare global { + namespace NodeJS { + interface ProcessEnv { + EXPO_PUBLIC_API_URL: string; + } + } +} +export {}; +``` + +## Offline & Connectivity + +Track device connectivity with `@react-native-community/netinfo` and wire it into React Query so queries automatically pause offline and resume on reconnect: + +```tsx +// app/_layout.tsx (once, at startup) +import { onlineManager } from "@tanstack/react-query"; +import NetInfo from "@react-native-community/netinfo"; + +onlineManager.setEventListener((setOnline) => + NetInfo.addEventListener((state) => setOnline(!!state.isConnected)), +); +``` + +To show an in-app banner, subscribe separately: + +```tsx +function useOnline() { + const [online, setOnline] = useState(true); + useEffect(() => NetInfo.addEventListener((s) => setOnline(!!s.isConnected)), []); + return online; +} +``` + +## Request Lifecycle + +### Cancellation + +When a component unmounts mid-request, abort the in-flight fetch to avoid setting state on an unmounted component: + +```tsx +useEffect(() => { + const ac = new AbortController(); + api.get(`/projects/${id}`, { signal: ac.signal }).then(setProject); + return () => ac.abort(); +}, [id]); +``` + +React Query handles cancellation automatically for queries — no extra work needed. + +### Retries + +React Query retries failed queries by default (3 attempts with exponential backoff). For mutations or non-React-Query code, implement manually: + +```tsx +async function withRetry(fn: () => Promise, attempts = 3): Promise { + for (let i = 0; i < attempts; i++) { + try { + return await fn(); + } catch (err) { + if (i === attempts - 1) throw err; + await new Promise((r) => setTimeout(r, 1000 * 2 ** i)); + } + } + throw new Error("unreachable"); +} +``` + +## Server-Side API Routes + +Expo Router supports `+api.ts` files that run on the server (deployed to EAS Hosting / Cloudflare Workers). Use them when you need to keep secrets server-side, proxy third-party APIs, or run database queries. + +### Conventions + +``` +app/ + api/ + health+api.ts → GET /api/health + projects+api.ts → GET|POST /api/projects + projects/[id]+api.ts → GET|PUT|DELETE /api/projects/:id + webhooks/payments+api.ts → POST /api/webhooks/payments +``` + +Export a named function per HTTP method: + +```ts +// app/api/projects+api.ts +export async function GET(req: Request) { + const url = new URL(req.url); + const cursor = url.searchParams.get("cursor"); + const rows = await db.query("SELECT * FROM projects WHERE id > ? LIMIT 20", [cursor ?? 0]); + return Response.json(rows); +} + +export async function POST(req: Request) { + const { name, description } = await req.json(); + const [row] = await db.insert(projectsTable).values({ name, description }).returning(); + return Response.json(row, { status: 201 }); +} +``` + +### Secrets + +Variables **without** the `EXPO_PUBLIC_` prefix are server-only: + +```ts +// app/api/ai/summarize+api.ts +const LLM_KEY = process.env.LLM_API_KEY; // never reaches the client bundle + +export async function POST(req: Request) { + const { text } = await req.json(); + const res = await fetch("https://api.llm.example.com/v1/chat", { + method: "POST", + headers: { Authorization: `Bearer ${LLM_KEY}`, "Content-Type": "application/json" }, + body: JSON.stringify({ messages: [{ role: "user", content: `Summarize: ${text}` }] }), + }); + return Response.json(await res.json()); +} +``` + +### Webhooks + +```ts +// app/api/webhooks/payments+api.ts — verify signature, then handle event +const event = stripe.webhooks.constructEvent(rawBody, sig, process.env.STRIPE_WH_SECRET!); +if (event.type === "checkout.session.completed") { + await activateSubscription(event.data.object.customer as string); +} +``` + +### Protecting Routes + +```ts +// lib/require-auth.ts — extract and verify JWT from Authorization header, throw Response on failure +export async function requireAuth(req: Request): Promise { + const header = req.headers.get("Authorization"); + if (!header?.startsWith("Bearer ")) + throw Response.json({ error: "unauthorized" }, { status: 401 }); + const uid = await verifyJwt(header.slice(7)); + if (!uid) throw Response.json({ error: "invalid token" }, { status: 401 }); + return uid; +} +// Usage in route: const uid = await requireAuth(req); +``` + +### Deploying + +```bash +npx expo export +eas deploy # preview +eas deploy --prod # production + +# Set server-only secrets +eas env:create --name LLM_API_KEY --value "sk-..." --environment production +``` + +API routes run on Cloudflare Workers — no `fs` module, 30 s CPU limit, use Web APIs (`fetch`, `crypto.subtle`) instead of Node built-ins. diff --git a/skills/react-native-dev/references/performance.md b/skills/react-native-dev/references/performance.md new file mode 100644 index 0000000..20ca59f --- /dev/null +++ b/skills/react-native-dev/references/performance.md @@ -0,0 +1,215 @@ +# Performance Reference + +Diagnosing and fixing performance issues in React Native / Expo apps. + +## Profiling Workflow + +Before optimizing, identify the actual bottleneck: + +1. **JS thread** — Open React Native DevTools (press `j` in Metro terminal) → Profiler tab → record interaction → look for components with long render times +2. **Native thread** — iOS: Xcode Instruments (Time Profiler); Android: Android Studio CPU Profiler +3. **Measure, don't guess** — Always reproduce the issue in a release-like build (`npx expo run:ios --configuration Release`) + +## Rendering + +### Virtualized Lists + +Never render large datasets inside a `ScrollView`. Use a virtualized list that recycles off-screen views: + +```tsx +import { FlashList } from "@shopify/flash-list"; + +function ProductCatalog({ products }: { products: Product[] }) { + return ( + } + estimatedItemSize={72} + keyExtractor={(p) => p.sku} + /> + ); +} + +const ProductRow = memo(function ProductRow({ product }: { product: Product }) { + return ( + + + {product.name} + + ); +}); +``` + +Key points: +- Wrap list items with `memo` to skip re-renders when props haven't changed +- Always provide `estimatedItemSize` — FlashList uses it for layout estimation +- Extract `renderItem` or use a named component to keep stable references + +### Minimizing Re-renders + +**Split state by concern.** A single large state object forces every subscriber to re-render on any change: + +```tsx +// Zustand — select only the slice you need +const count = useStore((s) => s.cart.itemCount); + +// Jotai — one atom per concern +const cartTotalAtom = atom((get) => { + const items = get(cartItemsAtom); + return items.reduce((sum, i) => sum + i.price * i.qty, 0); +}); +``` + +**React Compiler** (Expo SDK 54+) automatically memoizes components and hooks. Enable it to eliminate most manual `useMemo`/`useCallback`: + +```json +// app.json +{ "expo": { "experiments": { "reactCompiler": true } } } +``` + +### Deferred Updates + +When a state change triggers expensive computation (filtering a long list, rendering a complex tree), defer the update so typing or scrolling stays responsive: + +```tsx +const [search, setSearch] = useState(""); +const deferred = useDeferredValue(search); + +const results = useMemo( + () => catalog.filter((p) => p.name.toLowerCase().includes(deferred.toLowerCase())), + [catalog, deferred], +); +``` + +### TextInput on Android + +Controlled `TextInput` (with `value` + `onChangeText`) can lag on Android because every keystroke round-trips through the JS thread. For search bars or other high-frequency inputs, prefer uncontrolled mode: + +```tsx +const ref = useRef(null); + + handleSearch(e.nativeEvent.text)} +/> +``` + +## Startup Time (TTI) + +### Measuring + +```tsx +import { PerformanceObserver, performance } from "react-native-performance"; + +performance.mark("nativeLaunch"); + +export default function App() { + useEffect(() => { + performance.measure("TTI", "nativeLaunch"); + }, []); + // ... +} +``` + +Always measure **cold starts** — kill the app completely before each measurement. + +### Reducing TTI + +- **Android bundle mmap** — Set `expo.useLegacyPackaging=false` in `android/gradle.properties` so Hermes memory-maps the bundle instead of decompressing it +- **Preload heavy routes** — Call `preloadRouteAsync("/dashboard")` (from `expo-router`) while the user is still on the splash/login screen +- **Lazy-load non-critical screens** — Screens behind deep navigation don't need to be in the initial bundle + +## Bundle & App Size + +### Inspecting the Bundle + +```bash +npx expo export --platform ios --source-maps --output-dir dist +npx source-map-explorer dist/bundles/ios/*.js +``` + +Common wins: +- **Direct imports** — `import groupBy from "lodash/groupBy"` instead of `import { groupBy } from "lodash"` +- **Remove dead Intl polyfills** — Hermes ships with built-in `Intl` support since SDK 50 +- **Tree shaking** — Enable via `"experiments": { "treeShaking": true }` in app config (SDK 52+) + +### Shrinking the Native Binary + +```properties +# android/gradle.properties +android.enableProguardInReleaseBuilds=true +``` + +Inspect the final artifact: +- iOS: download the `.ipa` from EAS, unzip, check `Payload/*.app` size +- Android: open the `.aab`/`.apk` in Android Studio → Build → Analyze APK + +## Memory + +### Preventing Leaks + +Every subscription, listener, or long-lived resource acquired in `useEffect` must be cleaned up: + +```tsx +useEffect(() => { + const sub = AppState.addEventListener("change", onAppStateChange); + return () => sub.remove(); +}, []); +``` + +For fetch calls, pass an `AbortSignal` and abort on unmount: + +```tsx +useEffect(() => { + const ac = new AbortController(); + loadProducts(ac.signal); + return () => ac.abort(); +}, [categoryId]); +``` + +### Native Memory + +- Monitor with Xcode Memory Graph Debugger (iOS) or Android Studio Memory Profiler +- Release heavy native resources (camera sessions, audio players) in cleanup +- In Swift/Kotlin modules, watch for retain cycles — use `[weak self]` / `WeakReference` + +## Animations + +Only animate **`transform`** and **`opacity`**. These properties are composited on the GPU and don't trigger layout recalculation: + +```tsx +const style = useAnimatedStyle(() => ({ + opacity: withTiming(visible.value ? 1 : 0), + transform: [{ translateY: withSpring(offset.value) }], +})); +``` + +Animating `width`, `height`, `margin`, `padding`, or `top`/`left` forces the layout engine to re-measure on every frame — a common source of dropped frames. + +Keep gesture callbacks on the UI thread: + +```tsx +const drag = Gesture.Pan().onUpdate((e) => { + "worklet"; + translateX.value = e.translationX; +}); +``` + +## Native Module Performance + +- Prefer **async** Turbo Module methods — synchronous calls block the JS thread +- Use native SDK implementations over JS polyfills (`expo-crypto` over `crypto-js`, `react-native-mmkv` over AsyncStorage for hot paths) +- **Android 16KB page alignment** is required for Google Play (2025+). Verify third-party `.so` files are compiled with 16KB alignment + +## Troubleshooting Guide + +| Symptom | Where to Look | Likely Fix | +|---------|--------------|------------| +| Scroll jank in long lists | JS thread — re-renders | Virtualized list + memoized items | +| Typing lag in search bar | JS thread — controlled input | Uncontrolled `TextInput` with `defaultValue` | +| Slow cold start | Bundle size, sync init | Mmap bundle, preload routes, lazy screens | +| App binary too large | Native assets, unused libs | R8 (Android), analyze bundle, direct imports | +| Growing memory over time | Effect cleanup | Return teardown from every `useEffect` | +| Choppy enter/exit animation | Animated properties | Only `transform` + `opacity`, use worklets | +| Re-renders cascade across app | Global state shape | Atomic selectors (Zustand/Jotai), React Compiler | diff --git a/skills/react-native-dev/references/state-management.md b/skills/react-native-dev/references/state-management.md new file mode 100644 index 0000000..0fb3f52 --- /dev/null +++ b/skills/react-native-dev/references/state-management.md @@ -0,0 +1,230 @@ +# State Management Reference + +Patterns for local, shared, and server state in React Native / Expo apps. + +## Decision Guide + +| State Type | Solution | +|------------|----------| +| Local UI state (toggle, input) | `useState` / `useReducer` | +| Shared app-wide state | Zustand or Jotai | +| Server/async data | React Query (TanStack Query) | +| Form state | React Hook Form (see forms.md) | +| Auth / session | Zustand + `expo-secure-store` | + +**Avoid**: Redux for new projects (boilerplate), Context for high-frequency updates (re-render overhead). + +## useState / useReducer + +```tsx +// Simple toggle +const [isOpen, setIsOpen] = useState(false); + +// Complex local state — useReducer +type State = { count: number; status: "idle" | "loading" | "error" }; +type Action = { type: "increment" } | { type: "setStatus"; payload: State["status"] }; + +function reducer(state: State, action: Action): State { + switch (action.type) { + case "increment": return { ...state, count: state.count + 1 }; + case "setStatus": return { ...state, status: action.payload }; + } +} + +const [state, dispatch] = useReducer(reducer, { count: 0, status: "idle" }); +dispatch({ type: "increment" }); +``` + +## Zustand (Shared State) + +```bash +npx expo install zustand +``` + +```tsx +// stores/settings-store.ts +import { create } from "zustand"; +import { persist, createJSONStorage } from "zustand/middleware"; +import AsyncStorage from "@react-native-async-storage/async-storage"; + +interface SettingsStore { + theme: "light" | "dark"; + locale: string; + setTheme: (theme: "light" | "dark") => void; + setLocale: (locale: string) => void; +} + +export const useSettingsStore = create()( + persist( + (set) => ({ + theme: "light", + locale: "en", + setTheme: (theme) => set({ theme }), + setLocale: (locale) => set({ locale }), + }), + { + name: "settings-storage", + storage: createJSONStorage(() => AsyncStorage), + } + ) +); + +// Usage +const { theme, setTheme } = useSettingsStore(); +const locale = useSettingsStore((s) => s.locale); // Selector — minimizes re-renders +``` + +```tsx +// stores/cart-store.ts +interface CartStore { + items: CartItem[]; + add: (product: Product) => void; + remove: (id: string) => void; + clear: () => void; + total: () => number; +} + +export const useCartStore = create()((set, get) => ({ + items: [], + add: (product) => set((s) => ({ + items: [...s.items, { product, quantity: 1 }], + })), + remove: (id) => set((s) => ({ + items: s.items.filter((i) => i.product.id !== id), + })), + clear: () => set({ items: [] }), + total: () => get().items.reduce((sum, i) => sum + i.product.price * i.quantity, 0), +})); +``` + +## Jotai (Atomic State) + +```bash +npx expo install jotai +``` + +```tsx +// atoms/user-atoms.ts +import { atom } from "jotai"; +import { atomWithStorage, createJSONStorage } from "jotai/utils"; +import AsyncStorage from "@react-native-async-storage/async-storage"; + +const storage = createJSONStorage(() => AsyncStorage); + +export const userAtom = atom(null); +export const themeAtom = atomWithStorage<"light" | "dark">("theme", "light", storage); + +// Derived atom — computed from others +export const isAdminAtom = atom((get) => get(userAtom)?.role === "admin"); +``` + +```tsx +// Usage — component only re-renders when its atoms change +import { useAtom, useAtomValue, useSetAtom } from "jotai"; + +function Header() { + const user = useAtomValue(userAtom); // read-only + const setTheme = useSetAtom(themeAtom); // write-only + const [theme, setThemeRW] = useAtom(themeAtom); // read + write + return {user?.name}; +} +``` + +**Zustand vs Jotai**: +- **Zustand** — store-based, better for related state with actions (auth, cart) +- **Jotai** — atom-based, better for independent values, fine-grained subscriptions, avoids re-renders + +## React Query (Server State) + +See [networking.md](networking.md) for full reference. Key patterns: + +```tsx +// Queries — read +const { data, isLoading } = useQuery({ queryKey: ["users"], queryFn: fetchUsers }); + +// Mutations — write +const mutation = useMutation({ + mutationFn: createUser, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["users"] }), +}); + +// Optimistic update +const mutation = useMutation({ + mutationFn: updateUser, + onMutate: async (newUser) => { + await queryClient.cancelQueries({ queryKey: ["user", newUser.id] }); + const prev = queryClient.getQueryData(["user", newUser.id]); + queryClient.setQueryData(["user", newUser.id], newUser); + return { prev }; + }, + onError: (_err, variables, context) => { + queryClient.setQueryData(["user", variables.id], context?.prev); + }, +}); +``` + +## Minimize Re-renders + +### Zustand Selectors + +```tsx +// ✗ Wrong — re-renders on any store change +const store = useAuthStore(); + +// ✓ Correct — re-renders only when user changes +const user = useAuthStore((s) => s.user); +const logout = useAuthStore((s) => s.logout); // Actions are stable references +``` + +### Dispatcher Pattern + +```tsx +// ✗ Wrong — passes callbacks that recreate on every render +function Parent() { + const [count, setCount] = useState(0); + return setCount(c => c + 1)} />; +} + +// ✓ Correct — dispatcher reference is stable +function Parent() { + const [count, dispatch] = useReducer(reducer, 0); + return ; +} +``` + +### React Compiler (SDK 54+) + +With React Compiler enabled, `memo`, `useCallback`, and `useMemo` are often unnecessary: + +```json +// app.json +{ "expo": { "experiments": { "reactCompiler": true } } } +``` + +## Context (Use Sparingly) + +Context is suitable for infrequently-changing values (theme, locale, auth status). **Avoid** for high-frequency updates like scroll position or form input. + +```tsx +const ThemeContext = createContext<"light" | "dark">("light"); + +function ThemeProvider({ children }: { children: ReactNode }) { + const [theme, setTheme] = useState<"light" | "dark">("light"); + return {children}; // React 19+ +} + +// Consume +const theme = use(ThemeContext); // React 19+ +``` + +## Fallback on First Render + +```tsx +// ✓ Always show fallback while async state loads +function UserProfile({ userId }: { userId: string }) { + const { data, isLoading } = useQuery({ queryKey: ["user", userId], queryFn: () => fetchUser(userId) }); + if (isLoading) return ; + if (!data) return null; + return ; +} +``` diff --git a/skills/react-native-dev/references/styling.md b/skills/react-native-dev/references/styling.md new file mode 100644 index 0000000..4024af5 --- /dev/null +++ b/skills/react-native-dev/references/styling.md @@ -0,0 +1,117 @@ +# Styling Reference + +StyleSheet, NativeWind/Tailwind, platform-specific styles, and theming for Expo/React Native. + +## StyleSheet + +```tsx +import { StyleSheet } from "react-native"; +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: "#fff" }, + text: { fontSize: 16, fontWeight: "600" }, +}); +``` + +Prefer `StyleSheet.create` over inline style objects — it validates styles at creation time and enables potential future optimizations. + +## Platform-Specific Styles + +```tsx +import { Platform, StyleSheet } from "react-native"; + +const styles = StyleSheet.create({ + shadow: Platform.select({ + ios: { shadowColor: "#000", shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 4 }, + android: { elevation: 4 }, + }), +}); +``` + +Since React Native 0.76+, `boxShadow` is supported as a unified cross-platform shadow API. Prefer it over platform-specific shadow properties when targeting New Architecture. + +## NativeWind / Tailwind CSS + +For existing projects, check which NativeWind version is in `package.json` and follow the corresponding docs. For new projects, use NativeWind v4 (stable). + +### Installation (NativeWind v4) + +```bash +npx expo install nativewind tailwindcss@3 \ + tailwind-merge clsx +``` + +### Configuration + +```js +// babel.config.js +module.exports = function (api) { + api.cache(true); + return { + presets: ["babel-preset-expo"], + plugins: ["nativewind/babel"], + }; +}; +``` + +```js +// tailwind.config.js +module.exports = { + content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"], + presets: [require("nativewind/preset")], + theme: { extend: {} }, +}; +``` + +```css +/* global.css */ +@tailwind base; +@tailwind components; +@tailwind utilities; +``` + +```tsx +// app/_layout.tsx +import "../global.css"; +``` + +### Usage + +```tsx + + Title + + Button + + +``` + +### Conditional Classes + +```tsx +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs)); + + +``` + +## Theming and Dark Mode + +For apps using NativeWind, use Tailwind's `dark:` variant: + +```tsx + + Adaptive text + +``` + +For StyleSheet-based projects, read the system color scheme and map it to a theme object: + +```tsx +import { useColorScheme } from "react-native"; + +const colorScheme = useColorScheme(); // "light" | "dark" +``` + +Keep color tokens in a central `constants/colors.ts` file with light and dark variants. Pass the active palette via React Context or a Zustand store. diff --git a/skills/react-native-dev/references/testing.md b/skills/react-native-dev/references/testing.md new file mode 100644 index 0000000..a882c8d --- /dev/null +++ b/skills/react-native-dev/references/testing.md @@ -0,0 +1,342 @@ +# Testing Reference + +Jest, React Native Testing Library, and E2E testing for Expo/React Native apps. + +## Setup + +```bash +npx expo install jest-expo @testing-library/react-native +``` + +```json +// package.json +{ + "jest": { + "preset": "jest-expo", + "setupFilesAfterSetup": ["@testing-library/react-native/extend-expect"] + } +} +``` + +```bash +npx jest # Run all tests +npx jest --watch # Watch mode +npx jest --coverage # Coverage report +npx jest path/to/test.tsx # Single file +``` + +## React Native Testing Library + +### Basic Component Test + +```tsx +// components/__tests__/Button.test.tsx +import { render, fireEvent, screen } from "@testing-library/react-native"; +import { Button } from "../Button"; + +describe("Button", () => { + it("renders label", () => { + render(