├── .env
├── .gitignore
├── README.md
├── app.json
├── app
├── (tabs)
│ ├── _layout.tsx
│ ├── explore.tsx
│ └── index.tsx
├── +not-found.tsx
├── _hooks
│ └── usePokemon.ts
├── _layout.tsx
└── dev-tools-bubble
│ ├── DevTools.tsx
│ ├── DevToolsBubble.tsx
│ ├── _components
│ ├── _hooks
│ │ ├── useAllMutations.ts
│ │ ├── useAllQueries.ts
│ │ └── useQueryStatusCounts.ts
│ ├── _util
│ │ ├── actions
│ │ │ ├── dataSyncFromServer.ts
│ │ │ ├── deleteItem.ts
│ │ │ ├── invalidate.ts
│ │ │ ├── refetch.ts
│ │ │ ├── remove.ts
│ │ │ ├── reset.ts
│ │ │ ├── triggerError.ts
│ │ │ └── triggerLoading.ts
│ │ ├── deleteNestedDataByPath.ts
│ │ ├── getQueryStatusColor.ts
│ │ ├── getQueryStatusLabel.ts
│ │ ├── mutationStatusToColorClass.ts
│ │ ├── statusTobgColorClass.ts
│ │ └── updateNestedDataByPath.ts
│ └── devtools
│ │ ├── ActionButton.tsx
│ │ ├── ClearCacheButton.tsx
│ │ ├── DevToolsHeader.tsx
│ │ ├── Explorer.tsx
│ │ ├── MutationButton.tsx
│ │ ├── MutationDetails.tsx
│ │ ├── MutationDetailsChips.tsx
│ │ ├── MutationInformation.tsx
│ │ ├── MutationsList.tsx
│ │ ├── NetworkToggleButton.tsx
│ │ ├── QueriesList.tsx
│ │ ├── QueryActions.tsx
│ │ ├── QueryDetails.tsx
│ │ ├── QueryDetailsChip.tsx
│ │ ├── QueryInformation.tsx
│ │ ├── QueryRow.tsx
│ │ ├── QueryStatus.tsx
│ │ ├── QueryStatusCount.tsx
│ │ ├── displayValue.ts
│ │ └── svgs.tsx
│ ├── context
│ └── CopyContext.tsx
│ └── index.ts
├── assets
├── fonts
│ └── SpaceMono-Regular.ttf
└── images
│ ├── adaptive-icon.png
│ ├── favicon.png
│ ├── icon.png
│ ├── partial-react-logo.png
│ ├── react-logo.png
│ ├── react-logo@2x.png
│ ├── react-logo@3x.png
│ └── splash-icon.png
├── components
├── Collapsible.tsx
├── EnvDemo.tsx
├── ExternalLink.tsx
├── HapticTab.tsx
├── HelloWave.tsx
├── ParallaxScrollView.tsx
├── StorageDemo.tsx
├── ThemedText.tsx
├── ThemedView.tsx
├── __tests__
│ ├── ThemedText-test.tsx
│ └── __snapshots__
│ │ └── ThemedText-test.tsx.snap
└── ui
│ ├── IconSymbol.ios.tsx
│ ├── IconSymbol.tsx
│ ├── TabBarBackground.ios.tsx
│ └── TabBarBackground.tsx
├── constants
└── Colors.ts
├── hooks
├── useColorScheme.ts
├── useColorScheme.web.ts
└── useThemeColor.ts
├── package-lock.json
├── package.json
├── scripts
└── reset-project.js
├── storage
└── mmkv.ts
└── tsconfig.json
/.env:
--------------------------------------------------------------------------------
1 | # Mock Environment Variables for Testing
2 | EXPO_PUBLIC_API_URL=https://api.example.com
3 | EXPO_PUBLIC_APP_NAME=RN DevTools Example
4 | EXPO_PUBLIC_VERSION=1.0.0
5 | EXPO_PUBLIC_ENVIRONMENT=development
6 | EXPO_PUBLIC_FEATURE_FLAG_STORAGE_DEMO=true
7 | NODE_ENV=development
8 | EXPO_PUBLIC_DEBUG_MODE=true
9 | EXPO_PUBLIC_ANALYTICS_ENABLED=false
10 | SECRET_KEY=demo-secret-key-not-for-production
11 | DATABASE_URL=sqlite://demo.db
12 | API_SECRET=demo-api-secret
13 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
2 |
3 | # dependencies
4 | node_modules/
5 |
6 | # Expo
7 | .expo/
8 | dist/
9 | web-build/
10 | expo-env.d.ts
11 |
12 | # Native
13 | *.orig.*
14 | *.jks
15 | *.p8
16 | *.p12
17 | *.key
18 | *.mobileprovision
19 |
20 | # Metro
21 | .metro-health-check*
22 |
23 | # debug
24 | npm-debug.*
25 | yarn-debug.*
26 | yarn-error.*
27 |
28 | # macOS
29 | .DS_Store
30 | *.pem
31 |
32 | # local env files (commented out for example repo)
33 | # .env*.local
34 |
35 | # typescript
36 | *.tsbuildinfo
37 |
38 | # Android/iOS build directories
39 | android/
40 | ios/
41 |
42 | app-example
43 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React Native DevTools Example
2 |
3 | Enhanced developer tools for React Native applications, supporting React Query DevTools and device storage monitoring with a beautiful native interface.
4 |
5 | 
6 |
7 | https://github.com/user-attachments/assets/fce3cba3-b30a-409a-8f8f-db2bd28579be
8 |
9 |
10 | https://github.com/user-attachments/assets/24183264-fff2-4e7d-86f7-2775362cf485
11 |
12 |
13 | ## ✨ Features
14 |
15 | - 🔄 Real-time React Query state monitoring
16 | - 💾 **Device storage monitoring with CRUD operations** - MMKV, AsyncStorage, and SecureStorage
17 | - 🌐 **Environment variables monitoring** - View and track public environment variables
18 | - 🎨 Beautiful native macOS interface
19 | - 🚀 Automatic connection to React apps
20 | - 📊 Query status visualization
21 | - 🔌 Socket.IO integration for reliable communication
22 | - ⚡️ Simple setup with NPM package
23 | - 📱 Works with **any React-based platform**: React Native, React Web, Next.js, Expo, tvOS, VR, etc.
24 | - 🛑 Zero-config production safety - automatically disabled in production builds
25 |
26 | ## 🚀 Quick Start
27 |
28 | 1. Clone this repository
29 | 2. Install dependencies: `npm install`
30 | 3. Start the development server: `npm start`
31 | 4. Download and launch the [React Native DevTools](https://github.com/LovesWorking/rn-better-dev-tools) desktop app
32 | 5. The app will automatically connect and sync React Query state, storage, and environment variables
33 |
34 | ## 💾 Storage Demo
35 |
36 | This example app demonstrates real-time storage monitoring with:
37 |
38 | - **Mock MMKV Storage**: High-performance key-value storage simulation (using AsyncStorage for Expo Go compatibility)
39 | - **AsyncStorage**: React Native's standard async storage
40 | - **SecureStore**: Secure storage for sensitive data (Expo SecureStore)
41 |
42 | ### Features Demonstrated
43 |
44 | - **Real-time monitoring**: See storage changes as they happen in DevTools
45 | - **CRUD operations**: Create, read, update, and delete storage entries directly from the app
46 | - **React Query integration**: Access storage data via queries like `['#storage', 'mmkv', 'keyName']`
47 | - **Type-safe operations**: Automatic serialization/deserialization of complex data types
48 | - **Expo Go compatibility**: Mock MMKV implementation that works with Expo Go
49 |
50 | > **Note**: This demo uses a mock MMKV implementation for Expo Go compatibility. In a real development build or production app, you would use the actual [react-native-mmkv](https://github.com/mrousavy/react-native-mmkv) package for better performance.
51 |
52 | ## 🌐 Environment Variables Demo
53 |
54 | The app showcases environment variable monitoring with:
55 |
56 | - **Public variables**: `EXPO_PUBLIC_*` variables visible to the client
57 | - **Private variables**: Server-side only variables for development
58 | - **Real-time sync**: Environment variables are automatically synced with DevTools
59 | - **Visual indicators**: Clear distinction between public and private variables
60 |
61 | ### Example Environment Variables
62 |
63 | ```bash
64 | # Public variables (visible in client)
65 | EXPO_PUBLIC_API_URL=https://api.example.com
66 | EXPO_PUBLIC_APP_NAME=RN DevTools Example
67 | EXPO_PUBLIC_DEBUG_MODE=true
68 |
69 | # Private variables (development only)
70 | NODE_ENV=development
71 | SECRET_KEY=demo-secret-key-not-for-production
72 | DATABASE_URL=sqlite://demo.db
73 | ```
74 |
75 | ## 📱 Related Projects
76 |
77 | - **Desktop App**: [React Native DevTools](https://github.com/LovesWorking/rn-better-dev-tools)
78 | - **NPM Package**: [react-query-external-sync](https://www.npmjs.com/package/react-query-external-sync)
79 | - **Expo Plugin**: [tanstack-query-dev-tools-expo-plugin](https://github.com/LovesWorking/tanstack-query-dev-tools-expo-plugin)
80 |
81 | ## 🛠️ Technical Implementation
82 |
83 | This example uses:
84 |
85 | - **React Query v5** for state management
86 | - **Expo Router** for navigation
87 | - **Mock MMKV** for high-performance storage simulation (Expo Go compatible)
88 | - **AsyncStorage** for standard React Native storage
89 | - **Expo SecureStore** for secure data storage
90 | - **Socket.IO** for real-time communication with DevTools
91 |
92 | ## 📄 License
93 |
94 | MIT
95 |
96 | ---
97 |
98 | Made with ❤️ by [LovesWorking](https://github.com/LovesWorking)
99 |
--------------------------------------------------------------------------------
/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "name": "rn-dev-tools-exmaple",
4 | "slug": "rn-dev-tools-exmaple",
5 | "version": "1.0.0",
6 | "orientation": "portrait",
7 | "icon": "./assets/images/icon.png",
8 | "scheme": "myapp",
9 | "userInterfaceStyle": "automatic",
10 | "newArchEnabled": true,
11 | "ios": {
12 | "supportsTablet": true,
13 | "bundleIdentifier": "com.lovesworking.rndevtoolsexmaple"
14 | },
15 | "android": {
16 | "adaptiveIcon": {
17 | "foregroundImage": "./assets/images/adaptive-icon.png",
18 | "backgroundColor": "#ffffff"
19 | },
20 | "package": "com.lovesworking.rndevtoolsexmaple"
21 | },
22 | "web": {
23 | "bundler": "metro",
24 | "output": "static",
25 | "favicon": "./assets/images/favicon.png"
26 | },
27 | "plugins": [
28 | "expo-router",
29 | [
30 | "expo-splash-screen",
31 | {
32 | "image": "./assets/images/splash-icon.png",
33 | "imageWidth": 200,
34 | "resizeMode": "contain",
35 | "backgroundColor": "#ffffff"
36 | }
37 | ],
38 | "expo-font",
39 | "expo-web-browser"
40 | ],
41 | "experiments": {
42 | "typedRoutes": true
43 | }
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/app/(tabs)/_layout.tsx:
--------------------------------------------------------------------------------
1 | import { Tabs } from "expo-router";
2 | import React from "react";
3 | import { Platform, Animated, View } from "react-native";
4 |
5 | import { HapticTab } from "@/components/HapticTab";
6 | import { IconSymbol } from "@/components/ui/IconSymbol";
7 | import { Colors } from "@/constants/Colors";
8 | import { useColorScheme } from "@/hooks/useColorScheme";
9 | import { MaterialCommunityIcons } from "@expo/vector-icons";
10 |
11 | // Custom tab bar background that adapts to Pokémon colors
12 | const DynamicTabBarBackground = ({ color }: { color: string }) => {
13 | return (
14 |
26 | );
27 | };
28 |
29 | // Create a store to share Pokémon data across components
30 | // Add this in a new file: store/pokemonStore.ts
31 | // For now we'll define it here so you can copy it to the right place
32 | const createPokemonStore = () => {
33 | let listeners: (() => void)[] = [];
34 | let currentPokemonType: string | null = null;
35 |
36 | return {
37 | setPokemonType: (type: string | null) => {
38 | currentPokemonType = type;
39 | listeners.forEach((listener) => listener());
40 | },
41 | getPokemonType: () => currentPokemonType,
42 | subscribe: (listener: () => void) => {
43 | listeners.push(listener);
44 | return () => {
45 | listeners = listeners.filter((l) => l !== listener);
46 | };
47 | },
48 | };
49 | };
50 |
51 | export const pokemonStore = createPokemonStore();
52 | export const usePokemonStore = () => {
53 | const [pokemonType, setPokemonType] = React.useState(
54 | pokemonStore.getPokemonType()
55 | );
56 |
57 | React.useEffect(() => {
58 | return pokemonStore.subscribe(() => {
59 | setPokemonType(pokemonStore.getPokemonType());
60 | });
61 | }, []);
62 |
63 | return pokemonType;
64 | };
65 |
66 | export default function TabLayout() {
67 | const colorScheme = useColorScheme();
68 | const isDark = colorScheme === "dark";
69 | const pokemonType = usePokemonStore();
70 |
71 | // Get color based on Pokemon type
72 | const getTypeColor = (type: string | null) => {
73 | if (!type) return isDark ? "#1D3D47" : "#A1CEDC";
74 |
75 | const typeColors: Record = {
76 | normal: "#A8A878",
77 | fire: "#F08030",
78 | water: "#6890F0",
79 | electric: "#F8D030",
80 | grass: "#78C850",
81 | ice: "#98D8D8",
82 | fighting: "#C03028",
83 | poison: "#A040A0",
84 | ground: "#E0C068",
85 | flying: "#A890F0",
86 | psychic: "#F85888",
87 | bug: "#A8B820",
88 | rock: "#B8A038",
89 | ghost: "#705898",
90 | dragon: "#7038F8",
91 | dark: "#705848",
92 | steel: "#B8B8D0",
93 | fairy: "#EE99AC",
94 | };
95 | return typeColors[type] || "#68A090";
96 | };
97 |
98 | // Calculate if text should be black or white based on background brightness
99 | const getContrastColor = (hexColor: string) => {
100 | // Convert hex to RGB
101 | const r = parseInt(hexColor.substr(1, 2), 16);
102 | const g = parseInt(hexColor.substr(3, 2), 16);
103 | const b = parseInt(hexColor.substr(5, 2), 16);
104 |
105 | // Calculate brightness (YIQ equation)
106 | const brightness = (r * 299 + g * 587 + b * 114) / 1000;
107 |
108 | // Return black or white based on brightness
109 | return brightness > 128 ? "#000000" : "#FFFFFF";
110 | };
111 |
112 | // Get the colors based on current Pokémon type
113 | const tabBarColor = getTypeColor(pokemonType);
114 | const iconColor = getContrastColor(tabBarColor);
115 |
116 | return (
117 | ,
124 | tabBarStyle: {
125 | position: "absolute",
126 | elevation: 0,
127 | height: 60,
128 | borderTopWidth: 0,
129 | backgroundColor: "transparent",
130 | },
131 | tabBarLabelStyle: {
132 | fontWeight: "bold",
133 | fontSize: 12,
134 | },
135 | }}
136 | >
137 | (
142 |
143 | ),
144 | }}
145 | />
146 |
147 | );
148 | }
149 |
--------------------------------------------------------------------------------
/app/(tabs)/explore.tsx:
--------------------------------------------------------------------------------
1 | import { StyleSheet, Image, Platform } from 'react-native';
2 |
3 | import { Collapsible } from '@/components/Collapsible';
4 | import { ExternalLink } from '@/components/ExternalLink';
5 | import ParallaxScrollView from '@/components/ParallaxScrollView';
6 | import { ThemedText } from '@/components/ThemedText';
7 | import { ThemedView } from '@/components/ThemedView';
8 | import { IconSymbol } from '@/components/ui/IconSymbol';
9 |
10 | export default function TabTwoScreen() {
11 | return (
12 |
21 | }>
22 |
23 | Explore
24 |
25 | This app includes example code to help you get started.
26 |
27 |
28 | This app has two screens:{' '}
29 | app/(tabs)/index.tsx and{' '}
30 | app/(tabs)/explore.tsx
31 |
32 |
33 | The layout file in app/(tabs)/_layout.tsx{' '}
34 | sets up the tab navigator.
35 |
36 |
37 | Learn more
38 |
39 |
40 |
41 |
42 | You can open this project on Android, iOS, and the web. To open the web version, press{' '}
43 | w in the terminal running this project.
44 |
45 |
46 |
47 |
48 | For static images, you can use the @2x and{' '}
49 | @3x suffixes to provide files for
50 | different screen densities
51 |
52 |
53 |
54 | Learn more
55 |
56 |
57 |
58 |
59 | Open app/_layout.tsx to see how to load{' '}
60 |
61 | custom fonts such as this one.
62 |
63 |
64 |
65 | Learn more
66 |
67 |
68 |
69 |
70 | This template has light and dark mode support. The{' '}
71 | useColorScheme() hook lets you inspect
72 | what the user's current color scheme is, and so you can adjust UI colors accordingly.
73 |
74 |
75 | Learn more
76 |
77 |
78 |
79 |
80 | This template includes an example of an animated component. The{' '}
81 | components/HelloWave.tsx component uses
82 | the powerful react-native-reanimated{' '}
83 | library to create a waving hand animation.
84 |
85 | {Platform.select({
86 | ios: (
87 |
88 | The components/ParallaxScrollView.tsx{' '}
89 | component provides a parallax effect for the header image.
90 |
91 | ),
92 | })}
93 |
94 |
95 | );
96 | }
97 |
98 | const styles = StyleSheet.create({
99 | headerImage: {
100 | color: '#808080',
101 | bottom: -90,
102 | left: -35,
103 | position: 'absolute',
104 | },
105 | titleContainer: {
106 | flexDirection: 'row',
107 | gap: 8,
108 | },
109 | });
110 |
--------------------------------------------------------------------------------
/app/(tabs)/index.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Image,
3 | StyleSheet,
4 | TouchableOpacity,
5 | TextInput,
6 | ActivityIndicator,
7 | } from "react-native";
8 | import ParallaxScrollView from "@/components/ParallaxScrollView";
9 | import { ThemedText } from "@/components/ThemedText";
10 | import { ThemedView } from "@/components/ThemedView";
11 | import { useState, useEffect } from "react";
12 | import { usePokemon } from "../_hooks/usePokemon";
13 | import { Ionicons } from "@expo/vector-icons";
14 | import { useColorScheme } from "react-native";
15 | import { pokemonStore } from "./_layout";
16 | import { StorageDemo } from "@/components/StorageDemo";
17 | import { EnvDemo } from "@/components/EnvDemo";
18 |
19 | export const HomeScreen = () => {
20 | const [pokemonName, setPokemonName] = useState("pikachu");
21 | const [inputValue, setInputValue] = useState("");
22 | const { data, error, isLoading } = usePokemon(pokemonName);
23 | const colorScheme = useColorScheme();
24 | const isDark = colorScheme === "dark";
25 |
26 | // Track when we're intentionally loading a new Pokémon
27 | const [isChangingPokemon, setIsChangingPokemon] = useState(false);
28 |
29 | const handleSearch = () => {
30 | if (inputValue.trim()) {
31 | setIsChangingPokemon(true);
32 | setPokemonName(inputValue.trim().toLowerCase());
33 | }
34 | };
35 |
36 | const getRandomPokemon = () => {
37 | setIsChangingPokemon(true);
38 | // Pokémon IDs range from 1 to approximately 1010 in the latest generations
39 | const randomId = Math.floor(Math.random() * 1010) + 1;
40 | setPokemonName(randomId.toString());
41 | setInputValue(""); // Clear the input field
42 | };
43 |
44 | // Reset loading state when data changes
45 | useEffect(() => {
46 | if (data && isChangingPokemon) {
47 | setIsChangingPokemon(false);
48 | }
49 | }, [data]);
50 |
51 | // Update shared store when Pokémon changes
52 | useEffect(() => {
53 | if (data?.types?.length) {
54 | pokemonStore.setPokemonType(data.types[0]);
55 | }
56 | }, [data]);
57 |
58 | // Get color based on Pokemon type
59 | const getTypeColor = (type: string) => {
60 | const typeColors: Record = {
61 | normal: "#A8A878",
62 | fire: "#F08030",
63 | water: "#6890F0",
64 | electric: "#F8D030",
65 | grass: "#78C850",
66 | ice: "#98D8D8",
67 | fighting: "#C03028",
68 | poison: "#A040A0",
69 | ground: "#E0C068",
70 | flying: "#A890F0",
71 | psychic: "#F85888",
72 | bug: "#A8B820",
73 | rock: "#B8A038",
74 | ghost: "#705898",
75 | dragon: "#7038F8",
76 | dark: "#705848",
77 | steel: "#B8B8D0",
78 | fairy: "#EE99AC",
79 | };
80 | return typeColors[type] || "#68A090";
81 | };
82 |
83 | // Get header color based on Pokemon types
84 | const getHeaderColor = () => {
85 | if (!data?.types?.length) return { light: "#A1CEDC", dark: "#1D3D47" };
86 | const mainType = data.types[0];
87 | const color = getTypeColor(mainType);
88 | return {
89 | light: color,
90 | dark: isDark ? `${color}99` : color, // Add transparency for dark mode
91 | };
92 | };
93 |
94 | return (
95 |
104 | ) : (
105 |
106 |
110 |
111 | {isChangingPokemon ? "Catching Pokémon..." : "Loading..."}
112 |
113 |
114 | )
115 | }
116 | >
117 |
118 | {/* Search and Random Buttons */}
119 |
120 |
128 |
136 |
137 |
138 |
139 |
140 | {/* Random Button */}
141 |
148 |
154 |
155 | {isChangingPokemon ? "Searching..." : "Random Pokémon"}
156 |
157 |
158 |
159 | {/* Pokemon Content */}
160 | {isLoading || isChangingPokemon ? (
161 |
162 |
163 |
164 | {isChangingPokemon ? "Catching Pokémon..." : "Loading..."}
165 |
166 |
167 | ) : error ? (
168 |
169 |
170 |
171 | Pokémon not found! Try another name.
172 |
173 |
174 | ) : (
175 | data && (
176 |
177 | #{data.id}
178 | {data.name}
179 |
180 | {/* Types */}
181 |
182 | {data.types.map((type) => (
183 |
190 | {type}
191 |
192 | ))}
193 |
194 |
195 | {/* Basic Info */}
196 |
197 |
198 |
199 | {data.height} m
200 |
201 | Height
202 |
203 |
204 |
205 | {data.weight} kg
206 |
207 | Weight
208 |
209 |
210 |
211 | {/* Stats */}
212 | Base Stats
213 |
214 | {data.stats.map((stat) => (
215 |
216 |
217 | {stat.name.replace("-", " ")}
218 |
219 |
220 | {stat.value}
221 |
222 |
223 | 90
233 | ? "#78C850"
234 | : stat.value > 50
235 | ? "#6890F0"
236 | : "#F08030",
237 | },
238 | ]}
239 | />
240 |
241 |
242 | ))}
243 |
244 |
245 | )
246 | )}
247 |
248 | {/* Storage Demo Section */}
249 |
250 |
251 | {/* Environment Variables Demo Section */}
252 |
253 |
254 |
255 | );
256 | };
257 |
258 | const styles = StyleSheet.create({
259 | reactLogo: {
260 | height: 178,
261 | width: 290,
262 | bottom: 0,
263 | left: 0,
264 | position: "absolute",
265 | },
266 | pokemonHeaderImage: {
267 | height: 300,
268 | width: 300,
269 | alignSelf: "center",
270 | marginBottom: 20,
271 | },
272 | container: {
273 | padding: 16,
274 | borderTopLeftRadius: 30,
275 | borderTopRightRadius: 30,
276 | marginTop: -30,
277 | },
278 | searchContainer: {
279 | flexDirection: "row",
280 | alignItems: "center",
281 | marginBottom: 24,
282 | width: "100%",
283 | },
284 | input: {
285 | flex: 1,
286 | height: 50,
287 | borderRadius: 25,
288 | paddingHorizontal: 20,
289 | fontSize: 16,
290 | backgroundColor: "#f5f5f5",
291 | shadowColor: "#000",
292 | shadowOffset: { width: 0, height: 2 },
293 | shadowOpacity: 0.1,
294 | shadowRadius: 4,
295 | elevation: 2,
296 | color: "#333",
297 | },
298 | searchButton: {
299 | backgroundColor: "#3b82f6",
300 | width: 50,
301 | height: 50,
302 | borderRadius: 25,
303 | justifyContent: "center",
304 | alignItems: "center",
305 | marginLeft: 10,
306 | },
307 | loadingContainer: {
308 | alignItems: "center",
309 | justifyContent: "center",
310 | padding: 30,
311 | height: 300,
312 | },
313 | loadingText: {
314 | marginTop: 16,
315 | fontSize: 16,
316 | },
317 | errorContainer: {
318 | alignItems: "center",
319 | justifyContent: "center",
320 | padding: 30,
321 | height: 300,
322 | },
323 | errorText: {
324 | marginTop: 16,
325 | fontSize: 16,
326 | textAlign: "center",
327 | },
328 | pokemonContainer: {
329 | width: "100%",
330 | alignItems: "center",
331 | },
332 | pokemonId: {
333 | fontSize: 18,
334 | color: "#666",
335 | marginBottom: 4,
336 | },
337 | pokemonName: {
338 | marginTop: 10,
339 | paddingTop: 10,
340 | fontSize: 32,
341 | fontWeight: "bold",
342 | textTransform: "capitalize",
343 | marginBottom: 12,
344 | },
345 | typesContainer: {
346 | flexDirection: "row",
347 | gap: 10,
348 | marginBottom: 24,
349 | },
350 | typeTag: {
351 | paddingHorizontal: 14,
352 | paddingVertical: 6,
353 | borderRadius: 20,
354 | },
355 | typeText: {
356 | color: "#fff",
357 | fontSize: 14,
358 | fontWeight: "bold",
359 | textTransform: "capitalize",
360 | },
361 | infoContainer: {
362 | flexDirection: "row",
363 | justifyContent: "space-around",
364 | width: "100%",
365 | marginBottom: 24,
366 | paddingVertical: 16,
367 | borderRadius: 12,
368 | backgroundColor: "rgba(0,0,0,0.03)",
369 | },
370 | infoItem: {
371 | alignItems: "center",
372 | width: "45%",
373 | },
374 | infoLabel: {
375 | fontSize: 14,
376 | color: "#666",
377 | marginTop: 4,
378 | },
379 | infoValue: {
380 | fontSize: 20,
381 | fontWeight: "bold",
382 | },
383 | sectionTitle: {
384 | fontSize: 22,
385 | fontWeight: "bold",
386 | alignSelf: "flex-start",
387 | marginBottom: 12,
388 | marginTop: 10,
389 | },
390 | statsContainer: {
391 | width: "100%",
392 | marginBottom: 20,
393 | },
394 | statRow: {
395 | flexDirection: "row",
396 | alignItems: "center",
397 | marginBottom: 12,
398 | width: "100%",
399 | },
400 | statName: {
401 | width: 100,
402 | fontSize: 14,
403 | textTransform: "capitalize",
404 | },
405 | statValue: {
406 | width: 40,
407 | fontSize: 14,
408 | fontWeight: "bold",
409 | textAlign: "right",
410 | marginRight: 10,
411 | },
412 | statBarContainer: {
413 | flex: 1,
414 | height: 8,
415 | backgroundColor: "rgba(0,0,0,0.1)",
416 | borderRadius: 4,
417 | overflow: "hidden",
418 | },
419 | statBar: {
420 | height: "100%",
421 | borderRadius: 4,
422 | },
423 | randomButton: {
424 | backgroundColor: "#22c55e", // Green shade
425 | flexDirection: "row",
426 | alignItems: "center",
427 | justifyContent: "center",
428 | paddingVertical: 12,
429 | paddingHorizontal: 20,
430 | borderRadius: 25,
431 | marginBottom: 24,
432 | width: "100%",
433 | shadowColor: "#000",
434 | shadowOffset: { width: 0, height: 2 },
435 | shadowOpacity: 0.1,
436 | shadowRadius: 4,
437 | elevation: 2,
438 | },
439 | buttonText: {
440 | color: "#fff",
441 | fontWeight: "bold",
442 | fontSize: 16,
443 | },
444 | buttonIcon: {
445 | marginRight: 8,
446 | },
447 | loaderContainer: {
448 | height: 300,
449 | width: "100%",
450 | justifyContent: "center",
451 | alignItems: "center",
452 | },
453 | loaderText: {
454 | marginTop: 12,
455 | fontSize: 16,
456 | fontWeight: "bold",
457 | },
458 | disabledButton: {
459 | opacity: 0.7,
460 | },
461 | });
462 |
463 | export default HomeScreen;
464 |
--------------------------------------------------------------------------------
/app/+not-found.tsx:
--------------------------------------------------------------------------------
1 | import { Link, Stack } from "expo-router";
2 | import { StyleSheet } from "react-native";
3 |
4 | import { ThemedText } from "@/components/ThemedText";
5 | import { ThemedView } from "@/components/ThemedView";
6 |
7 | export default function NotFoundScreen() {
8 | return (
9 | <>
10 |
11 |
12 | This screen doesn't exist.
13 |
14 | Go to home screen!
15 |
16 |
17 | >
18 | );
19 | }
20 |
21 | const styles = StyleSheet.create({
22 | container: {
23 | flex: 1,
24 | alignItems: "center",
25 | justifyContent: "center",
26 | padding: 20,
27 | },
28 | link: {
29 | marginTop: 15,
30 | paddingVertical: 15,
31 | },
32 | });
33 |
--------------------------------------------------------------------------------
/app/_hooks/usePokemon.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 |
3 | // Expanded interface with more useful Pokemon data
4 | interface PokemonData {
5 | id: number;
6 | name: string;
7 | height: number;
8 | weight: number;
9 | image: string | null; // Main image
10 | types: string[]; // Pokemon types (e.g., fire, water)
11 | stats: {
12 | name: string;
13 | value: number;
14 | }[];
15 | }
16 |
17 | const fetchPokemon = async (pokemonName: string): Promise => {
18 | const response = await fetch(
19 | `https://pokeapi.co/api/v2/pokemon/${pokemonName}`
20 | );
21 | if (!response.ok) {
22 | throw new Error("Network response was not ok");
23 | }
24 | const data = await response.json();
25 |
26 | // Create a more detailed but still clean data object
27 | return {
28 | id: data.id,
29 | name: data.name,
30 | height: data.height / 10, // Convert to meters
31 | weight: data.weight / 10, // Convert to kg
32 | image:
33 | data.sprites.other["official-artwork"].front_default ||
34 | data.sprites.front_default,
35 | types: data.types.map((t: any) => t.type.name),
36 | stats: data.stats.map((s: any) => ({
37 | name: s.stat.name,
38 | value: s.base_stat,
39 | })),
40 | };
41 | };
42 |
43 | export const usePokemon = (pokemonName: string) => {
44 | return useQuery({
45 | queryKey: [`Pokemon-${pokemonName}`],
46 | queryFn: () => fetchPokemon(pokemonName),
47 | enabled: pokemonName.length > 0,
48 | });
49 | };
50 |
--------------------------------------------------------------------------------
/app/_layout.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | DarkTheme,
3 | DefaultTheme,
4 | ThemeProvider,
5 | } from "@react-navigation/native";
6 | import { useFonts } from "expo-font";
7 | import { Stack } from "expo-router";
8 | import * as SplashScreen from "expo-splash-screen";
9 | import { StatusBar } from "expo-status-bar";
10 | import { useEffect } from "react";
11 | import "react-native-reanimated";
12 | import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
13 | import * as Clipboard from "expo-clipboard";
14 | // import { useSyncQueries } from "tanstack-query-dev-tools-expo-plugin";
15 | import { useSyncQueriesExternal } from "react-query-external-sync";
16 | import { useColorScheme } from "@/hooks/useColorScheme";
17 | import { Platform } from "react-native";
18 | import AsyncStorage from "@react-native-async-storage/async-storage";
19 | import * as SecureStore from "expo-secure-store";
20 | import { storage } from "../storage/mmkv";
21 | import { DevToolsBubble } from "./dev-tools-bubble";
22 |
23 | // Prevent the splash screen from auto-hiding before asset loading is complete.
24 | SplashScreen.preventAutoHideAsync();
25 |
26 | // Create QueryClient as a singleton outside the component
27 | const queryClient = new QueryClient();
28 |
29 | // Initialize default storage values
30 | const initializeDefaultStorageValues = async () => {
31 | try {
32 | // Set default MMKV value
33 | await storage.setAsync("demo_mmkv_value", "Hello from Mock MMKV!");
34 |
35 | // Set default AsyncStorage value
36 | await AsyncStorage.setItem("demo_async_value", "Hello from AsyncStorage!");
37 |
38 | // Set default SecureStore value
39 | await SecureStore.setItemAsync("userToken", "demo-jwt-token-12345");
40 |
41 | console.log("Default storage values initialized");
42 | } catch (error) {
43 | console.error("Error initializing default storage values:", error);
44 | }
45 | };
46 |
47 | export default function RootLayout() {
48 | // Expo dev plugin
49 | // useSyncQueries({ queryClient });
50 | // New external devtools with storage and environment variable sync
51 | useSyncQueriesExternal({
52 | queryClient,
53 | socketURL: "http://localhost:42831", // Default port for React Native DevTools
54 | deviceName: Platform?.OS + "pokemon", // Platform detection
55 | platform: Platform?.OS, // Use appropriate platform identifier
56 | deviceId: Platform?.OS + "pokemon", // Use a PERSISTENT identifier (see note below)
57 | extraDeviceInfo: {
58 | // Optional additional info about your device
59 | appVersion: "1.0.0",
60 | // Add any relevant platform info
61 | },
62 | enableLogs: true, // Enable logs to see storage sync in action
63 | envVariables: {
64 | NODE_ENV: process.env.NODE_ENV || "development",
65 | SECRET_KEY: process.env.SECRET_KEY || "demo-secret-key",
66 | DATABASE_URL: process.env.DATABASE_URL || "sqlite://demo.db",
67 | API_SECRET: process.env.API_SECRET || "demo-api-secret",
68 | // Public environment variables are automatically loaded
69 | },
70 | // Storage monitoring with CRUD operations
71 | // mmkvStorage: storage, // MMKV storage for ['#storage', 'mmkv', 'key'] queries + monitoring
72 | // asyncStorage: AsyncStorage, // AsyncStorage for ['#storage', 'async', 'key'] queries + monitoring
73 | // secureStorage: SecureStore, // SecureStore for ['#storage', 'secure', 'key'] queries + monitoring
74 | // secureStorageKeys: [
75 | // "userToken",
76 | // "refreshToken",
77 | // "biometricKey",
78 | // "deviceId",
79 | // "userPreferences",
80 | // "authSecret",
81 | // ], // SecureStore keys to monitor
82 | });
83 | const colorScheme = useColorScheme();
84 | const [loaded] = useFonts({
85 | SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
86 | });
87 |
88 | useEffect(() => {
89 | if (loaded) {
90 | SplashScreen.hideAsync();
91 | }
92 | }, [loaded]);
93 |
94 | // Initialize default storage values on app load
95 | useEffect(() => {
96 | initializeDefaultStorageValues();
97 | }, []);
98 |
99 | if (!loaded) {
100 | return null;
101 | }
102 |
103 | return (
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 | {
114 | try {
115 | console.log("Attempting to copy:", text);
116 | await Clipboard.setStringAsync(text);
117 | console.log("Copy successful");
118 | return true;
119 | } catch (error) {
120 | console.error("Failed to copy to clipboard:", error);
121 | return false;
122 | }
123 | }}
124 | />
125 |
126 | );
127 | }
128 |
--------------------------------------------------------------------------------
/app/dev-tools-bubble/DevTools.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import {
3 | View,
4 | TouchableOpacity,
5 | Text,
6 | StyleSheet,
7 | PanResponderInstance,
8 | } from "react-native";
9 | import {
10 | Query,
11 | Mutation,
12 | onlineManager,
13 | useQueryClient,
14 | } from "@tanstack/react-query";
15 | import QueriesList from "./_components/devtools/QueriesList";
16 | import Svg, { Path } from "react-native-svg";
17 | import MutationsList from "./_components/devtools/MutationsList";
18 | import DevToolsHeader from "./_components/devtools/DevToolsHeader";
19 |
20 | interface Props {
21 | setShowDevTools: React.Dispatch>;
22 | onSelectionChange?: (hasSelection: boolean) => void;
23 | panResponder?: PanResponderInstance;
24 | }
25 |
26 | export default function DevTools({
27 | setShowDevTools,
28 | onSelectionChange,
29 | panResponder,
30 | }: Props) {
31 | const queryClient = useQueryClient();
32 | const [showQueries, setShowQueries] = useState(true);
33 | const [selectedQuery, setSelectedQuery] = useState(
34 | undefined
35 | );
36 | const [selectedMutation, setSelectedMutation] = useState<
37 | Mutation | undefined
38 | >(undefined);
39 | const [isOffline, setIsOffline] = useState(!onlineManager.isOnline());
40 |
41 | // Clear selections when switching tabs
42 | const handleTabChange = (newShowQueries: boolean) => {
43 | if (newShowQueries !== showQueries) {
44 | setSelectedQuery(undefined);
45 | setSelectedMutation(undefined);
46 | }
47 | setShowQueries(newShowQueries);
48 | };
49 |
50 | // Handle network toggle
51 | const handleToggleNetwork = () => {
52 | const newOfflineState = !isOffline;
53 | setIsOffline(newOfflineState);
54 | onlineManager.setOnline(!newOfflineState);
55 | };
56 |
57 | // Handle cache clearing
58 | const handleClearCache = () => {
59 | if (showQueries) {
60 | queryClient.getQueryCache().clear();
61 | setSelectedQuery(undefined);
62 | } else {
63 | queryClient.getMutationCache().clear();
64 | setSelectedMutation(undefined);
65 | }
66 | };
67 |
68 | // Notify parent when selection state changes
69 | React.useEffect(() => {
70 | const hasSelection =
71 | selectedQuery !== undefined || selectedMutation !== undefined;
72 | onSelectionChange?.(hasSelection);
73 | }, [selectedQuery, selectedMutation, onSelectionChange]);
74 |
75 | return (
76 |
77 | {
79 | setShowDevTools(false);
80 | }}
81 | style={styles.closeButton}
82 | >
83 |
92 |
93 |
94 |
104 | {showQueries ? (
105 |
109 | ) : (
110 |
114 | )}
115 |
116 |
117 | );
118 | }
119 | const styles = StyleSheet.create({
120 | container: {
121 | flex: 1,
122 | flexDirection: "column",
123 | },
124 | closeButton: {
125 | position: "absolute",
126 | right: -2,
127 | top: -17,
128 | zIndex: 50,
129 | width: 22,
130 | height: 15,
131 | borderTopLeftRadius: 4,
132 | borderTopRightRadius: 4,
133 | backgroundColor: "white",
134 | padding: 3,
135 | margin: 3,
136 | borderColor: "#98a2b3",
137 | borderWidth: 1,
138 | borderBottomWidth: 0,
139 | alignItems: "center",
140 | justifyContent: "center",
141 | },
142 | devToolsPanel: {
143 | backgroundColor: "white",
144 | minWidth: 300,
145 | flex: 1,
146 | borderTopColor: "#98a2b3",
147 | borderTopWidth: 1,
148 | },
149 | comingSoonText: {
150 | margin: 3,
151 | },
152 | });
153 |
--------------------------------------------------------------------------------
/app/dev-tools-bubble/DevToolsBubble.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useRef } from "react";
2 | import {
3 | View,
4 | TouchableOpacity,
5 | Platform,
6 | StyleSheet,
7 | ViewStyle,
8 | StyleProp,
9 | Dimensions,
10 | PanResponder,
11 | Animated,
12 | } from "react-native";
13 | import DevTools from "./DevTools";
14 | import { TanstackLogo } from "./_components/devtools/svgs";
15 | import { ClipboardFunction, CopyContext } from "./context/CopyContext";
16 |
17 | interface DevToolsBubbleProps {
18 | bubbleStyle?: StyleProp;
19 | onCopy?: ClipboardFunction;
20 | }
21 |
22 | export function DevToolsBubble({ bubbleStyle, onCopy }: DevToolsBubbleProps) {
23 | const [showDevTools, setShowDevTools] = useState(false);
24 | const [hasSelection, setHasSelection] = useState(false);
25 |
26 | // Get screen dimensions
27 | const screenHeight = Dimensions.get("window").height;
28 | const expandedHeight = screenHeight * 0.75;
29 | const defaultHeight = 350;
30 | const minHeight = 200; // Minimum height for the panel
31 | const maxHeight = screenHeight * 0.9; // Maximum height (90% of screen)
32 |
33 | // Animated value for height
34 | const heightAnim = useRef(
35 | new Animated.Value(hasSelection ? expandedHeight : defaultHeight)
36 | ).current;
37 | const [currentHeight, setCurrentHeight] = useState(
38 | hasSelection ? expandedHeight : defaultHeight
39 | );
40 | const currentHeightRef = useRef(
41 | hasSelection ? expandedHeight : defaultHeight
42 | );
43 |
44 | // Update height when selection changes
45 | React.useEffect(() => {
46 | const targetHeight = hasSelection ? expandedHeight : defaultHeight;
47 | setCurrentHeight(targetHeight);
48 | currentHeightRef.current = targetHeight;
49 | Animated.timing(heightAnim, {
50 | toValue: targetHeight,
51 | duration: 300,
52 | useNativeDriver: false,
53 | }).start();
54 | }, [hasSelection, expandedHeight, defaultHeight, heightAnim]);
55 |
56 | // Pan responder for dragging
57 | const panResponder = useRef(
58 | PanResponder.create({
59 | onMoveShouldSetPanResponder: (evt, gestureState) => {
60 | // Only respond to vertical movements
61 | return (
62 | Math.abs(gestureState.dy) > Math.abs(gestureState.dx) &&
63 | Math.abs(gestureState.dy) > 10
64 | );
65 | },
66 | onPanResponderGrant: () => {
67 | // Stop any ongoing animations and sync the ref with current animated value
68 | heightAnim.stopAnimation((value) => {
69 | setCurrentHeight(value);
70 | currentHeightRef.current = value;
71 | heightAnim.setValue(value);
72 | });
73 | },
74 | onPanResponderMove: (evt, gestureState) => {
75 | // Use the ref value which is always current
76 | const newHeight = currentHeightRef.current - gestureState.dy;
77 |
78 | // Clamp the height between min and max
79 | const clampedHeight = Math.max(
80 | minHeight,
81 | Math.min(maxHeight, newHeight)
82 | );
83 | heightAnim.setValue(clampedHeight);
84 | },
85 | onPanResponderRelease: (evt, gestureState) => {
86 | // Calculate the final height using the ref
87 | const finalHeight = Math.max(
88 | minHeight,
89 | Math.min(maxHeight, currentHeightRef.current - gestureState.dy)
90 | );
91 |
92 | // Update both state and ref immediately
93 | setCurrentHeight(finalHeight);
94 | currentHeightRef.current = finalHeight;
95 |
96 | // Animate to the final height and ensure sync
97 | Animated.timing(heightAnim, {
98 | toValue: finalHeight,
99 | duration: 200,
100 | useNativeDriver: false,
101 | }).start(() => {
102 | // Ensure the animated value and state are perfectly synced after animation
103 | heightAnim.setValue(finalHeight);
104 | setCurrentHeight(finalHeight);
105 | currentHeightRef.current = finalHeight;
106 | });
107 | },
108 | })
109 | ).current;
110 |
111 | return (
112 |
113 |
114 | {showDevTools ? (
115 |
116 |
121 |
122 | ) : (
123 | {
125 | setShowDevTools(true);
126 | }}
127 | style={[
128 | styles.touchableOpacityBase,
129 | Platform.OS === "ios"
130 | ? styles.touchableOpacityIOS
131 | : styles.touchableOpacityAndroid,
132 | bubbleStyle,
133 | ]}
134 | >
135 |
136 |
137 | )}
138 |
139 |
140 | );
141 | }
142 |
143 | const styles = StyleSheet.create({
144 | devTools: {
145 | position: "absolute",
146 | right: 0,
147 | bottom: 0,
148 | zIndex: 50,
149 | width: "100%",
150 | // height is now dynamic, controlled by Animated.Value
151 | },
152 | touchableOpacityBase: {
153 | position: "absolute",
154 | right: 1,
155 | zIndex: 50,
156 | width: 48,
157 | height: 48,
158 | borderRadius: 24,
159 | borderWidth: 4,
160 | borderColor: "#A4C200",
161 | },
162 | touchableOpacityIOS: {
163 | bottom: 96,
164 | },
165 | touchableOpacityAndroid: {
166 | bottom: 64,
167 | },
168 | text: {
169 | zIndex: 10,
170 | color: "white",
171 | fontSize: 40,
172 | padding: 24,
173 | },
174 | });
175 |
--------------------------------------------------------------------------------
/app/dev-tools-bubble/_components/_hooks/useAllMutations.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState } from "react";
2 | import { Mutation, useQueryClient } from "@tanstack/react-query";
3 | import isEqual from "fast-deep-equal";
4 |
5 | function useAllMutations() {
6 | const queryClient = useQueryClient();
7 | const [mutations, setMutations] = useState([]);
8 | const mutationsRef = useRef([]);
9 | useEffect(() => {
10 | const updateMutations = () => {
11 | // Only update state if the new mutations array is different
12 | setTimeout(() => {
13 | const newMutations = [...queryClient.getMutationCache().getAll()];
14 | const newStates = newMutations.map((mutation) => mutation.state);
15 | if (!isEqual(mutationsRef.current, newStates)) {
16 | mutationsRef.current = newStates; // Update the ref
17 | setMutations(newMutations); // Update state
18 | }
19 | }, 1);
20 | };
21 | // Perform an initial update
22 | updateMutations();
23 | // Subscribe to the query cache to run updates on changes
24 | const unsubscribe = queryClient
25 | .getMutationCache()
26 | .subscribe(updateMutations);
27 | // Cleanup the subscription when the component unmounts
28 | return () => unsubscribe();
29 | }, [queryClient]);
30 |
31 | return { mutations };
32 | }
33 |
34 | export default useAllMutations;
35 |
--------------------------------------------------------------------------------
/app/dev-tools-bubble/_components/_hooks/useAllQueries.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { Query, useQueryClient } from "@tanstack/react-query";
3 | function useAllQueries() {
4 | const queryClient = useQueryClient();
5 | const [queries, setQueries] = useState([]);
6 | useEffect(() => {
7 | const updateQueries = () => {
8 | const allQueries = queryClient.getQueryCache().findAll();
9 | setTimeout(() => {
10 | setQueries(allQueries);
11 | }, 1);
12 | };
13 | // Perform an initial update
14 | updateQueries();
15 | // Subscribe to the query cache to run updates on changes
16 | const unsubscribe = queryClient.getQueryCache().subscribe(updateQueries);
17 | // Cleanup the subscription when the component unmounts
18 | return () => unsubscribe();
19 | }, [queryClient]);
20 |
21 | return queries;
22 | }
23 |
24 | export default useAllQueries;
25 |
--------------------------------------------------------------------------------
/app/dev-tools-bubble/_components/_hooks/useQueryStatusCounts.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { useQueryClient } from "@tanstack/react-query";
3 | import { getQueryStatusLabel } from "../_util/getQueryStatusLabel";
4 |
5 | interface QueryStatusCounts {
6 | fresh: number;
7 | stale: number;
8 | fetching: number;
9 | paused: number;
10 | inactive: number;
11 | }
12 |
13 | function useQueryStatusCounts(): QueryStatusCounts {
14 | const queryClient = useQueryClient();
15 | const [counts, setCounts] = useState({
16 | fresh: 0,
17 | stale: 0,
18 | fetching: 0,
19 | paused: 0,
20 | inactive: 0,
21 | });
22 |
23 | useEffect(() => {
24 | const updateCounts = () => {
25 | const allQueries = queryClient.getQueryCache().getAll();
26 |
27 | const newCounts = allQueries.reduce(
28 | (acc, query) => {
29 | const status = getQueryStatusLabel(query);
30 | acc[status] = (acc[status] || 0) + 1;
31 | return acc;
32 | },
33 | { fresh: 0, stale: 0, fetching: 0, paused: 0, inactive: 0 }
34 | );
35 |
36 | setCounts(newCounts);
37 | };
38 |
39 | // Perform an initial update
40 | updateCounts();
41 |
42 | // Subscribe to the query cache to run updates on changes
43 | const unsubscribe = queryClient.getQueryCache().subscribe(updateCounts);
44 |
45 | // Cleanup the subscription when the component unmounts
46 | return () => unsubscribe();
47 | }, [queryClient]);
48 |
49 | return counts;
50 | }
51 |
52 | export default useQueryStatusCounts;
53 |
--------------------------------------------------------------------------------
/app/dev-tools-bubble/_components/_util/actions/dataSyncFromServer.ts:
--------------------------------------------------------------------------------
1 | import { Query, useQueryClient } from "@tanstack/react-query";
2 |
3 | interface Props {
4 | queryClient: ReturnType;
5 | query: Query;
6 | }
7 | export default function dataSyncFromServer({ query, queryClient }: Props) {
8 | queryClient.resetQueries({
9 | queryKey: query.queryKey,
10 | exact: true,
11 | });
12 | }
13 |
--------------------------------------------------------------------------------
/app/dev-tools-bubble/_components/_util/actions/deleteItem.ts:
--------------------------------------------------------------------------------
1 | import { Query, QueryClient } from "@tanstack/react-query";
2 | import { deleteNestedDataByPath } from "../deleteNestedDataByPath";
3 |
4 | interface Props {
5 | queryClient: QueryClient;
6 | activeQuery: Query;
7 | dataPath: Array | undefined;
8 | }
9 | export default function deleteItem({
10 | activeQuery,
11 | dataPath,
12 | queryClient,
13 | }: Props) {
14 | if (!dataPath) {
15 | console.error("delete item data path is missing!");
16 | return;
17 | }
18 | const oldData = activeQuery.state.data;
19 | const newData = deleteNestedDataByPath(oldData, dataPath);
20 | queryClient.setQueryData(activeQuery.queryKey, newData);
21 | }
22 |
--------------------------------------------------------------------------------
/app/dev-tools-bubble/_components/_util/actions/invalidate.ts:
--------------------------------------------------------------------------------
1 | import { Query, useQueryClient } from "@tanstack/react-query";
2 |
3 | interface Props {
4 | query: Query;
5 | queryClient: ReturnType;
6 | }
7 |
8 | export default function invalidate({ query, queryClient }: Props) {
9 | // This matches the ACTION-INVALIDATE case from the external sync system
10 | queryClient.invalidateQueries(query);
11 | }
12 |
--------------------------------------------------------------------------------
/app/dev-tools-bubble/_components/_util/actions/refetch.ts:
--------------------------------------------------------------------------------
1 | import { Query } from "@tanstack/react-query";
2 |
3 | interface Props {
4 | query: Query;
5 | }
6 |
7 | export default function refetch({ query }: Props) {
8 | // This matches the ACTION-REFETCH case from the external sync system
9 | const promise = query.fetch();
10 | promise.catch((error) => {
11 | // Log fetch errors but don't propagate them
12 | console.error(`Refetch error for query:`, error);
13 | });
14 | }
15 |
--------------------------------------------------------------------------------
/app/dev-tools-bubble/_components/_util/actions/remove.ts:
--------------------------------------------------------------------------------
1 | import { Query, useQueryClient } from "@tanstack/react-query";
2 |
3 | interface Props {
4 | queryClient: ReturnType;
5 | query: Query;
6 | }
7 | export default function remove({ query, queryClient }: Props) {
8 | queryClient.removeQueries(query);
9 | }
10 |
--------------------------------------------------------------------------------
/app/dev-tools-bubble/_components/_util/actions/reset.ts:
--------------------------------------------------------------------------------
1 | import { Query, useQueryClient } from "@tanstack/react-query";
2 |
3 | interface Props {
4 | queryClient: ReturnType;
5 | query: Query;
6 | }
7 | export default function reset({ query, queryClient }: Props) {
8 | queryClient.resetQueries({
9 | queryKey: query.queryKey,
10 | exact: true,
11 | });
12 | }
13 |
--------------------------------------------------------------------------------
/app/dev-tools-bubble/_components/_util/actions/triggerError.ts:
--------------------------------------------------------------------------------
1 | import { Query, useQueryClient } from "@tanstack/react-query";
2 |
3 | interface Props {
4 | queryClient: ReturnType;
5 | query: Query;
6 | }
7 |
8 | export default function triggerError({ query, queryClient }: Props) {
9 | if (query.state.status !== "error") {
10 | // --ACTION-TRIGGER-ERROR logic--
11 | // This matches the ACTION-TRIGGER-ERROR case from the external sync system
12 | const error = new Error("Unknown error from devtools");
13 | const __previousQueryOptions = query.options;
14 |
15 | query.setState({
16 | status: "error",
17 | error,
18 | fetchMeta: {
19 | ...query.state.fetchMeta,
20 | // @ts-expect-error This does exist
21 | __previousQueryOptions,
22 | },
23 | });
24 | } else {
25 | // --ACTION-RESTORE-ERROR logic--
26 | // This matches the ACTION-RESTORE-ERROR case from the external sync system
27 | queryClient.resetQueries(query);
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/app/dev-tools-bubble/_components/_util/actions/triggerLoading.ts:
--------------------------------------------------------------------------------
1 | import { Query } from "@tanstack/react-query";
2 |
3 | interface Props {
4 | query: Query;
5 | }
6 |
7 | export default function triggerLoading({ query }: Props) {
8 | if (query.state.data === undefined) {
9 | // --ACTION-RESTORE-LOADING logic--
10 | // This matches the ACTION-RESTORE-LOADING case from the external sync system
11 | const previousState = query.state;
12 | const previousOptions = query.state.fetchMeta
13 | ? (
14 | query.state.fetchMeta as unknown as {
15 | __previousQueryOptions: unknown;
16 | }
17 | ).__previousQueryOptions
18 | : null;
19 |
20 | query.cancel({ silent: true });
21 | query.setState({
22 | ...previousState,
23 | fetchStatus: "idle",
24 | fetchMeta: null,
25 | });
26 |
27 | if (previousOptions) {
28 | query.fetch(previousOptions);
29 | }
30 | } else {
31 | // --ACTION-TRIGGER-LOADING logic--
32 | // This matches the ACTION-TRIGGER-LOADING case from the external sync system
33 | const __previousQueryOptions = query.options;
34 |
35 | // Trigger a fetch in order to trigger suspense as well.
36 | query.fetch({
37 | ...__previousQueryOptions,
38 | queryFn: () => {
39 | return new Promise(() => {
40 | // Never resolve - simulates perpetual loading
41 | });
42 | },
43 | gcTime: -1,
44 | });
45 |
46 | query.setState({
47 | data: undefined,
48 | status: "pending",
49 | fetchMeta: {
50 | ...query.state.fetchMeta,
51 | // @ts-expect-error This does exist
52 | __previousQueryOptions,
53 | },
54 | });
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/app/dev-tools-bubble/_components/_util/deleteNestedDataByPath.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Deletes nested data by path
3 | *
4 | * @param {unknown} oldData Data to be updated
5 | * @param {Array} deletePath Path to the data to be deleted
6 | * @returns newData without the deleted items by path
7 | */
8 | export const deleteNestedDataByPath = (
9 | oldData: unknown,
10 | deletePath: Array
11 | ): any => {
12 | if (oldData instanceof Map) {
13 | const newData = new Map(oldData);
14 |
15 | if (deletePath.length === 1) {
16 | newData.delete(deletePath[0]);
17 | return newData;
18 | }
19 |
20 | const [head, ...tail] = deletePath;
21 | newData.set(head, deleteNestedDataByPath(newData.get(head), tail));
22 | return newData;
23 | }
24 |
25 | if (oldData instanceof Set) {
26 | const setAsArray = deleteNestedDataByPath(Array.from(oldData), deletePath);
27 | return new Set(setAsArray);
28 | }
29 |
30 | if (Array.isArray(oldData)) {
31 | const newData = [...oldData];
32 |
33 | if (deletePath.length === 1) {
34 | return newData.filter((_, idx) => idx.toString() !== deletePath[0]);
35 | }
36 |
37 | const [head, ...tail] = deletePath;
38 |
39 | // @ts-expect-error NAS
40 | newData[head] = deleteNestedDataByPath(newData[head], tail);
41 |
42 | return newData;
43 | }
44 |
45 | if (oldData instanceof Object) {
46 | const newData = { ...oldData };
47 |
48 | if (deletePath.length === 1) {
49 | // @ts-expect-error NAS
50 | delete newData[deletePath[0]];
51 | return newData;
52 | }
53 |
54 | const [head, ...tail] = deletePath;
55 | // @ts-expect-error NAS
56 | newData[head] = deleteNestedDataByPath(newData[head], tail);
57 |
58 | return newData;
59 | }
60 |
61 | return oldData;
62 | };
63 |
--------------------------------------------------------------------------------
/app/dev-tools-bubble/_components/_util/getQueryStatusColor.ts:
--------------------------------------------------------------------------------
1 | import type { Query } from "@tanstack/query-core";
2 |
3 | export function getQueryStatusColor({
4 | queryState,
5 | observerCount,
6 | isStale,
7 | }: {
8 | queryState: Query["state"];
9 | observerCount: number;
10 | isStale: boolean;
11 | }) {
12 | return queryState.fetchStatus === "fetching"
13 | ? "blue"
14 | : !observerCount
15 | ? "gray"
16 | : queryState.fetchStatus === "paused"
17 | ? "purple"
18 | : isStale
19 | ? "yellow"
20 | : "green";
21 | }
22 |
--------------------------------------------------------------------------------
/app/dev-tools-bubble/_components/_util/getQueryStatusLabel.ts:
--------------------------------------------------------------------------------
1 | import { QueryKey, Query } from "@tanstack/react-query";
2 | type QueryStatus = "fetching" | "inactive" | "paused" | "stale" | "fresh";
3 |
4 | export function getQueryStatusLabel(
5 | query: Query
6 | ): QueryStatus {
7 | return query.state.fetchStatus === "fetching"
8 | ? "fetching"
9 | : !query.getObserversCount()
10 | ? "inactive"
11 | : query.state.fetchStatus === "paused"
12 | ? "paused"
13 | : query.isStale()
14 | ? "stale"
15 | : "fresh";
16 | }
17 |
--------------------------------------------------------------------------------
/app/dev-tools-bubble/_components/_util/mutationStatusToColorClass.ts:
--------------------------------------------------------------------------------
1 | import { Mutation } from "@tanstack/react-query";
2 |
3 | const colors = {
4 | purpleMutation: "#D9D6FE",
5 | purpleMutationText: "#5925DC",
6 | redMutation: "#fecaca",
7 | redMutationText: "#b91c1c",
8 | yellowMutation: "#FEDF89",
9 | yellowMutationText: "#B54708",
10 | greenMutation: "#A6F4C5",
11 | greenMutationText: "#027A48",
12 | grayMutation: "#eaecf0",
13 | grayMutationText: "#344054",
14 | };
15 |
16 | export const getMutationStatusColors = ({
17 | status,
18 | isPaused,
19 | }: {
20 | status: Mutation["state"]["status"];
21 | isPaused: boolean;
22 | }) => {
23 | let backgroundColor, textColor;
24 |
25 | if (isPaused) {
26 | backgroundColor = colors.purpleMutation;
27 | textColor = colors.purpleMutationText;
28 | } else if (status === "error") {
29 | backgroundColor = colors.redMutation;
30 | textColor = colors.redMutationText;
31 | } else if (status === "pending") {
32 | backgroundColor = colors.yellowMutation;
33 | textColor = colors.yellowMutationText;
34 | } else if (status === "success") {
35 | backgroundColor = colors.greenMutation;
36 | textColor = colors.greenMutationText;
37 | } else {
38 | backgroundColor = colors.grayMutation;
39 | textColor = colors.grayMutationText;
40 | }
41 |
42 | return { backgroundColor, textColor };
43 | };
44 |
--------------------------------------------------------------------------------
/app/dev-tools-bubble/_components/_util/statusTobgColorClass.ts:
--------------------------------------------------------------------------------
1 | type StatusColorMap = {
2 | [key: string]: { backgroundColor: string };
3 | };
4 |
5 | const statusToBgColorStyle: StatusColorMap = {
6 | fresh: { backgroundColor: "#A6F4C5" }, // Green
7 | stale: { backgroundColor: "#FEDF89" }, // Yellow
8 | fetching: { backgroundColor: "#B2DDFF" }, // Blue
9 | paused: { backgroundColor: "#D9D6FE" }, // Indigo
10 | noObserver: { backgroundColor: "#EAECF0" }, // Grey
11 | inactive: { backgroundColor: "#FEDF89" }, // Yellow
12 | };
13 |
14 | export function getStatusBgColorStyle(status: string): {
15 | backgroundColor: string;
16 | } {
17 | const defaultStyle = { backgroundColor: "#EAECF0" }; // Default to "noObserver" color
18 | return statusToBgColorStyle[status] || defaultStyle;
19 | }
20 |
--------------------------------------------------------------------------------
/app/dev-tools-bubble/_components/_util/updateNestedDataByPath.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * updates nested data by path
3 | *
4 | * @param {unknown} oldData Data to be updated
5 | * @param {Array} updatePath Path to the data to be updated
6 | * @param {unknown} value New value
7 | */
8 | export const updateNestedDataByPath = (
9 | oldData: unknown,
10 | updatePath: Array,
11 | value: unknown
12 | ): any => {
13 | if (updatePath.length === 0) {
14 | return value;
15 | }
16 |
17 | if (oldData instanceof Map) {
18 | const newData = new Map(oldData);
19 |
20 | if (updatePath.length === 1) {
21 | newData.set(updatePath[0], value);
22 | return newData;
23 | }
24 |
25 | const [head, ...tail] = updatePath;
26 | newData.set(head, updateNestedDataByPath(newData.get(head), tail, value));
27 | return newData;
28 | }
29 |
30 | if (oldData instanceof Set) {
31 | const setAsArray = updateNestedDataByPath(
32 | Array.from(oldData),
33 | updatePath,
34 | value
35 | );
36 |
37 | return new Set(setAsArray);
38 | }
39 |
40 | if (Array.isArray(oldData)) {
41 | const newData = [...oldData];
42 |
43 | if (updatePath.length === 1) {
44 | // @ts-expect-error NAS
45 | newData[updatePath[0]] = value;
46 | return newData;
47 | }
48 |
49 | const [head, ...tail] = updatePath;
50 | // @ts-expect-error NAS
51 | newData[head] = updateNestedDataByPath(newData[head], tail, value);
52 |
53 | return newData;
54 | }
55 |
56 | if (oldData instanceof Object) {
57 | const newData = { ...oldData };
58 |
59 | if (updatePath.length === 1) {
60 | // @ts-expect-error NAS
61 | newData[updatePath[0]] = value;
62 | return newData;
63 | }
64 |
65 | const [head, ...tail] = updatePath;
66 | // @ts-expect-error NAS
67 | newData[head] = updateNestedDataByPath(newData[head], tail, value);
68 |
69 | return newData;
70 | }
71 |
72 | return oldData;
73 | };
74 |
--------------------------------------------------------------------------------
/app/dev-tools-bubble/_components/devtools/ActionButton.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { TouchableOpacity, Text, View, StyleSheet } from "react-native";
3 |
4 | // Define the color mappings
5 | const buttonColors = {
6 | btnRefetch: "#1570EF",
7 | btnInvalidate: "#DC6803",
8 | btnReset: "#475467",
9 | btnRemove: "#db2777",
10 | btnTriggerLoading: "#0891b2",
11 | btnTriggerLoadiError: "#ef4444",
12 | };
13 |
14 | const textColorMappings = {
15 | btnRefetch: "#1570EF",
16 | btnInvalidate: "#DC6803",
17 | btnReset: "#475467",
18 | btnRemove: "#db2777",
19 | btnTriggerLoading: "#0891b2",
20 | btnTriggerLoadiError: "#ef4444",
21 | };
22 |
23 | interface Props {
24 | onClick: () => void;
25 | text: string;
26 | bgColorClass: keyof typeof buttonColors;
27 | textColorClass: keyof typeof textColorMappings;
28 | disabled: boolean;
29 | }
30 |
31 | export default function ActionButton({
32 | onClick,
33 | text,
34 | textColorClass,
35 | bgColorClass,
36 | disabled,
37 | }: Props) {
38 | // Map class names to actual color values
39 | const backgroundColor = buttonColors[bgColorClass];
40 | const textColor = textColorMappings[textColorClass] || "#FFFFFF"; // Default text color
41 |
42 | return (
43 |
48 |
49 |
52 | {text}
53 |
54 |
55 | );
56 | }
57 |
58 | const styles = StyleSheet.create({
59 | button: {
60 | flexDirection: "row",
61 | alignItems: "center",
62 | justifyContent: "center",
63 | borderRadius: 4,
64 | borderWidth: 1,
65 | borderColor: "#d0d5dd",
66 | backgroundColor: "#f2f4f7",
67 | height: 32,
68 | paddingHorizontal: 10,
69 | paddingVertical: 6,
70 | },
71 | dot: {
72 | width: 6,
73 | height: 6,
74 | borderRadius: 999,
75 | marginRight: 6,
76 | },
77 | text: {
78 | fontSize: 12,
79 | fontWeight: "400",
80 | },
81 | });
82 |
--------------------------------------------------------------------------------
/app/dev-tools-bubble/_components/devtools/ClearCacheButton.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { TouchableOpacity, StyleSheet } from "react-native";
3 | import { Svg, Path } from "react-native-svg";
4 |
5 | interface ClearCacheButtonProps {
6 | type: "queries" | "mutations";
7 | onClear: () => void;
8 | disabled?: boolean;
9 | }
10 |
11 | const ClearCacheButton: React.FC = ({
12 | type,
13 | onClear,
14 | disabled = false,
15 | }) => {
16 | return (
17 |
26 |
27 |
28 | );
29 | };
30 |
31 | // Trash icon component
32 | const TrashIcon = () => (
33 |
48 | );
49 |
50 | const styles = StyleSheet.create({
51 | button: {
52 | width: 24,
53 | height: 24,
54 | borderRadius: 4,
55 | backgroundColor: "#f9fafb",
56 | justifyContent: "center",
57 | alignItems: "center",
58 | borderWidth: 1,
59 | borderColor: "#e5e7eb",
60 | },
61 | disabledButton: {
62 | opacity: 0.5,
63 | backgroundColor: "#f3f4f6",
64 | },
65 | });
66 |
67 | export default ClearCacheButton;
68 |
--------------------------------------------------------------------------------
/app/dev-tools-bubble/_components/devtools/DevToolsHeader.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {
3 | View,
4 | TouchableOpacity,
5 | Text,
6 | StyleSheet,
7 | PanResponderInstance,
8 | } from "react-native";
9 | import QueryStatusCount from "./QueryStatusCount";
10 | import NetworkToggleButton from "./NetworkToggleButton";
11 | import ClearCacheButton from "./ClearCacheButton";
12 |
13 | interface Props {
14 | showQueries: boolean;
15 | setShowQueries: React.Dispatch>;
16 | setShowDevTools: React.Dispatch>;
17 | onTabChange?: (showQueries: boolean) => void;
18 | panResponder?: PanResponderInstance;
19 | isOffline: boolean;
20 | onToggleNetwork: () => void;
21 | onClearCache: () => void;
22 | }
23 |
24 | export default function DevToolsHeader({
25 | showQueries,
26 | setShowQueries,
27 | setShowDevTools,
28 | onTabChange,
29 | panResponder,
30 | isOffline,
31 | onToggleNetwork,
32 | onClearCache,
33 | }: Props) {
34 | const handleTabChange = (newShowQueries: boolean) => {
35 | if (onTabChange) {
36 | onTabChange(newShowQueries);
37 | } else {
38 | setShowQueries(newShowQueries);
39 | }
40 | };
41 |
42 | return (
43 |
44 | {/* Drag indicator */}
45 |
46 |
47 |
48 | {
51 | setShowDevTools(false);
52 | }}
53 | accessibilityLabel="Close Tanstack query devtools"
54 | >
55 | TANSTACK
56 | React Native
57 |
58 |
59 |
60 | {
62 | handleTabChange(true);
63 | }}
64 | style={[
65 | styles.toggleButton,
66 | showQueries === true
67 | ? styles.toggleButtonActive
68 | : styles.toggleButtonInactive,
69 | {
70 | borderTopRightRadius: 0,
71 | borderBottomRightRadius: 0,
72 | },
73 | ]}
74 | >
75 |
83 | Queries
84 |
85 |
86 | {
88 | handleTabChange(false);
89 | }}
90 | style={[
91 | styles.toggleButton,
92 | showQueries === false
93 | ? styles.toggleButtonActive
94 | : styles.toggleButtonInactive,
95 | {
96 | borderTopLeftRadius: 0,
97 | borderBottomLeftRadius: 0,
98 | },
99 | ]}
100 | >
101 |
109 | Mutations
110 |
111 |
112 |
113 |
114 |
118 |
119 |
120 |
121 |
122 |
123 |
124 | );
125 | }
126 |
127 | const styles = StyleSheet.create({
128 | devToolsHeader: {
129 | padding: 4,
130 | paddingBottom: 4,
131 | paddingTop: 8,
132 | borderColor: "#d0d5dd",
133 | borderBottomWidth: 2,
134 | flexDirection: "column",
135 | gap: 4,
136 | minHeight: 60,
137 | },
138 | dragIndicator: {
139 | width: 50,
140 | height: 5,
141 | backgroundColor: "#98a2b3",
142 | borderRadius: 3,
143 | alignSelf: "center",
144 | marginBottom: 6,
145 | opacity: 0.8,
146 | },
147 | mainRow: {
148 | flexDirection: "row",
149 | flexWrap: "wrap",
150 | alignItems: "center",
151 | justifyContent: "flex-start",
152 | gap: 8,
153 | },
154 | tanstackHeader: {
155 | flexDirection: "column",
156 | gap: 2,
157 | marginHorizontal: 2,
158 | paddingRight: 8,
159 | backgroundColor: "transparent",
160 | borderWidth: 0,
161 | padding: 0,
162 | },
163 | tanstackText: {
164 | fontSize: 16,
165 | fontWeight: "bold",
166 | lineHeight: 16,
167 | color: "#475467",
168 | },
169 | reactNativeText: {
170 | fontSize: 12,
171 | fontWeight: "600",
172 | color: "#ea4037",
173 | marginTop: -4,
174 | },
175 | toggleButtonsContainer: {
176 | flexDirection: "row",
177 | marginLeft: 1,
178 | alignItems: "center",
179 | },
180 | toggleButton: {
181 | borderTopLeftRadius: 4,
182 | borderBottomLeftRadius: 4,
183 | padding: 4,
184 | borderWidth: 1,
185 | borderColor: "#d0d5dd",
186 | paddingHorizontal: 2,
187 | maxWidth: 100,
188 | borderRadius: 4,
189 | },
190 | toggleButtonActive: {
191 | backgroundColor: "#F2F4F7",
192 | },
193 | toggleButtonInactive: {
194 | backgroundColor: "#EAECF0",
195 | },
196 | toggleButtonText: {
197 | paddingRight: 4,
198 | paddingLeft: 4,
199 | fontSize: 12,
200 | },
201 | toggleButtonTextActive: {
202 | color: "#344054",
203 | },
204 | toggleButtonTextInactive: {
205 | color: "#909193",
206 | },
207 | });
208 |
--------------------------------------------------------------------------------
/app/dev-tools-bubble/_components/devtools/Explorer.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useMemo } from "react";
2 | import { Query, QueryKey, useQueryClient } from "@tanstack/react-query";
3 | import { Check, CopiedCopier, Copier, ErrorCopier, List, Trash } from "./svgs";
4 | import { updateNestedDataByPath } from "../_util/updateNestedDataByPath";
5 | import { displayValue } from "./displayValue";
6 | import deleteItem from "../_util/actions/deleteItem";
7 | import Svg, { Path } from "react-native-svg";
8 | import {
9 | Text,
10 | TextInput,
11 | TouchableOpacity,
12 | View,
13 | StyleSheet,
14 | Alert,
15 | } from "react-native";
16 | import { useCopy } from "../../context/CopyContext";
17 |
18 | function isIterable(x: any): x is Iterable {
19 | return Symbol.iterator in x;
20 | }
21 | /**
22 | * Chunk elements in the array by size
23 | *
24 | * when the array cannot be chunked evenly by size, the last chunk will be
25 | * filled with the remaining elements
26 | *
27 | * @example
28 | * chunkArray(['a','b', 'c', 'd', 'e'], 2) // returns [['a','b'], ['c', 'd'], ['e']]
29 | */
30 | function chunkArray(
31 | array: Array,
32 | size: number
33 | ): Array> {
34 | if (size < 1) return [];
35 | let i = 0;
36 | const result: Array> = [];
37 | while (i < array.length) {
38 | result.push(array.slice(i, i + size));
39 | i = i + size;
40 | }
41 | return result;
42 | }
43 | const Expander = ({ expanded }: { expanded: boolean }) => {
44 | return (
45 |
46 |
49 |
50 | );
51 | };
52 | type CopyState = "NoCopy" | "SuccessCopy" | "ErrorCopy";
53 | const CopyButton = ({ value }: { value: any }) => {
54 | const [copyState, setCopyState] = useState("NoCopy");
55 | const { onCopy } = useCopy();
56 |
57 | const handleCopy = async () => {
58 | if (!onCopy) {
59 | Alert.alert(
60 | "Warning",
61 | "Copy functionality is not configured. Please add a copy function to DevToolsBubble. See documentation for setup instructions."
62 | );
63 | return;
64 | }
65 |
66 | try {
67 | const copied = await onCopy(JSON.stringify(value));
68 | if (copied) {
69 | setCopyState("SuccessCopy");
70 | setTimeout(() => setCopyState("NoCopy"), 1500);
71 | } else {
72 | setCopyState("ErrorCopy");
73 | setTimeout(() => setCopyState("NoCopy"), 1500);
74 | }
75 | } catch (error) {
76 | console.error("Copy failed:", error);
77 | setCopyState("ErrorCopy");
78 | setTimeout(() => setCopyState("NoCopy"), 1500);
79 | }
80 | };
81 | return (
82 |
93 | {copyState === "NoCopy" && }
94 | {copyState === "SuccessCopy" && }
95 | {copyState === "ErrorCopy" && }
96 |
97 | );
98 | };
99 | const DeleteItemButton = ({
100 | dataPath,
101 | activeQuery,
102 | }: {
103 | dataPath: Array;
104 | activeQuery: Query | undefined;
105 | }) => {
106 | const queryClient = useQueryClient();
107 | if (!activeQuery) return null;
108 | return (
109 | {
111 | deleteItem({
112 | queryClient,
113 | activeQuery,
114 | dataPath,
115 | });
116 | }}
117 | style={styles.buttonStyle1}
118 | accessibilityLabel="Delete item"
119 | >
120 |
121 |
122 | );
123 | };
124 | const ClearArrayButton = ({
125 | dataPath,
126 | activeQuery,
127 | }: {
128 | dataPath: Array;
129 | activeQuery: Query | undefined;
130 | }) => {
131 | const queryClient = useQueryClient();
132 | if (!activeQuery) return null;
133 |
134 | const handleClear = () => {
135 | const oldData = activeQuery.state.data;
136 | const newData = updateNestedDataByPath(oldData, dataPath, []);
137 | queryClient.setQueryData(activeQuery.queryKey, newData);
138 | };
139 |
140 | return (
141 |
146 |
147 |
148 | );
149 | };
150 | const ToggleValueButton = ({
151 | dataPath,
152 | activeQuery,
153 | value,
154 | }: {
155 | dataPath: Array;
156 | activeQuery: Query | undefined;
157 | value: any;
158 | }) => {
159 | const queryClient = useQueryClient();
160 | if (!activeQuery) return null;
161 |
162 | const handleClick = () => {
163 | const oldData = activeQuery.state.data;
164 | const newData = updateNestedDataByPath(oldData, dataPath, !value);
165 | queryClient.setQueryData(activeQuery.queryKey, newData);
166 | };
167 |
168 | return (
169 |
174 |
175 |
176 | );
177 | };
178 | type Props = {
179 | editable?: boolean; // true
180 | label: string; //Data
181 | value: any; //unknown; // activeQueryStateData()
182 | defaultExpanded?: Array; // {['Data']} // Label for Data Explorer
183 | activeQuery?: Query | undefined; // activeQuery()
184 | dataPath?: Array;
185 | itemsDeletable?: boolean;
186 | };
187 | export default function Explorer({
188 | editable,
189 | label,
190 | value,
191 | defaultExpanded,
192 | activeQuery,
193 | dataPath,
194 | itemsDeletable,
195 | }: Props) {
196 | const queryClient = useQueryClient();
197 |
198 | // Explorer's section is expanded or collapsed
199 | const [isExpanded, setIsExpanded] = useState(
200 | (defaultExpanded || []).includes(label)
201 | );
202 | const toggleExpanded = () => setIsExpanded((old) => !old);
203 | const [expandedPages, setExpandedPages] = useState>([]);
204 |
205 | // Flattens data to label and value properties for easy rendering.
206 | const subEntries = useMemo(() => {
207 | if (Array.isArray(value)) {
208 | // Handle if array
209 | return value.map((d, i) => ({
210 | label: i.toString(),
211 | value: d,
212 | }));
213 | } else if (
214 | value !== null &&
215 | typeof value === "object" &&
216 | isIterable(value)
217 | ) {
218 | // Handle if object
219 | if (value instanceof Map) {
220 | return Array.from(value, ([key, val]) => ({
221 | label: key.toString(),
222 | value: val,
223 | }));
224 | }
225 | return Array.from(value, (val, i) => ({
226 | label: i.toString(),
227 | value: val,
228 | }));
229 | } else if (typeof value === "object" && value !== null) {
230 | return Object.entries(value).map(([key, val]) => ({
231 | label: key,
232 | value: val,
233 | }));
234 | }
235 | return [];
236 | }, [value]);
237 |
238 | // Identifies the data type of the value prop (e.g., 'array', 'Iterable', 'object')
239 | const valueType = useMemo(() => {
240 | if (Array.isArray(value)) {
241 | return "array";
242 | } else if (
243 | value !== null &&
244 | typeof value === "object" &&
245 | isIterable(value) &&
246 | typeof value[Symbol.iterator] === "function"
247 | ) {
248 | return "Iterable";
249 | } else if (typeof value === "object" && value !== null) {
250 | return "object";
251 | }
252 | return typeof value;
253 | }, [value]);
254 |
255 | // Takes a long list of items and divides it into smaller groups or 'chunks'.
256 | const subEntryPages = useMemo(() => {
257 | return chunkArray(subEntries, 100);
258 | }, [subEntries]);
259 |
260 | const currentDataPath = dataPath ?? []; // NOT USED FOR DATA EXPLORER
261 |
262 | const handleChange = (isNumber: boolean, newValue: string) => {
263 | if (!activeQuery) return null;
264 | const oldData = activeQuery.state.data;
265 | // If isNumber and newValue is not a number, return
266 | if (isNumber && isNaN(Number(newValue))) return;
267 | const updatedValue = valueType === "number" ? Number(newValue) : newValue;
268 | const newData = updateNestedDataByPath(
269 | oldData,
270 | currentDataPath,
271 | updatedValue
272 | );
273 | queryClient.setQueryData(activeQuery.queryKey, newData);
274 | };
275 |
276 | return (
277 |
278 |
279 | {subEntryPages.length > 0 && (
280 | <>
281 |
282 | toggleExpanded()}
285 | >
286 |
287 | {label}
288 | {`${
289 | String(valueType).toLowerCase() === "iterable"
290 | ? "(Iterable) "
291 | : ""
292 | }${subEntries.length} ${
293 | subEntries.length > 1 ? `items` : `item`
294 | }`}
295 |
296 | {editable && (
297 |
298 |
299 | {itemsDeletable && activeQuery !== undefined && (
300 |
304 | )}
305 | {valueType === "array" && activeQuery !== undefined && (
306 |
310 | )}
311 |
312 | )}
313 |
314 | {isExpanded && (
315 | <>
316 | {subEntryPages.length === 1 && (
317 |
318 | {subEntries.map((entry, index) => (
319 |
333 | ))}
334 |
335 | )}
336 | {subEntryPages.length > 1 && (
337 |
338 | {subEntryPages.map((entries, index) => (
339 |
340 |
341 |
343 | setExpandedPages((old) =>
344 | old.includes(index)
345 | ? old.filter((d) => d !== index)
346 | : [...old, index]
347 | )
348 | }
349 | style={styles.pageExpanderButton}
350 | >
351 |
354 |
355 | [{index * 100}...{index * 100 + 99}]
356 |
357 |
358 | {expandedPages.includes(index) && (
359 |
360 | {entries.map((entry) => (
361 |
370 | ))}
371 |
372 | )}
373 |
374 |
375 | ))}
376 |
377 | )}
378 | >
379 | )}
380 | >
381 | )}
382 | {subEntryPages.length === 0 && (
383 |
384 | {label}:
385 | {editable &&
386 | activeQuery !== undefined &&
387 | (valueType === "string" ||
388 | valueType === "number" ||
389 | valueType === "boolean") ? (
390 | <>
391 | {editable &&
392 | activeQuery &&
393 | (valueType === "string" || valueType === "number") && (
394 |
395 |
407 | handleChange(valueType === "number", newValue)
408 | }
409 | />
410 | {valueType === "number" && (
411 |
412 | {
415 | // Increment function
416 | const oldData = activeQuery.state.data;
417 | const newData = updateNestedDataByPath(
418 | oldData,
419 | currentDataPath,
420 | value + 1
421 | );
422 | queryClient.setQueryData(
423 | activeQuery.queryKey,
424 | newData
425 | );
426 | }}
427 | >
428 |
435 |
436 | {
439 | // Decrement function
440 | const oldData = activeQuery.state.data;
441 | const newData = updateNestedDataByPath(
442 | oldData,
443 | currentDataPath,
444 | value - 1
445 | );
446 | queryClient.setQueryData(
447 | activeQuery.queryKey,
448 | newData
449 | );
450 | }}
451 | >
452 |
463 |
464 |
465 | )}
466 |
467 | )}
468 | {valueType === "boolean" && (
469 |
470 |
475 |
476 | {displayValue(value)}
477 |
478 |
479 | )}
480 | >
481 | ) : (
482 | {displayValue(value)}
483 | )}
484 | {editable && itemsDeletable && activeQuery !== undefined && (
485 |
489 | )}
490 |
491 | )}
492 |
493 |
494 | );
495 | }
496 | const styles = StyleSheet.create({
497 | buttonStyle3: {
498 | backgroundColor: "transparent",
499 | flexDirection: "row",
500 | alignItems: "center",
501 | justifyContent: "center",
502 | width: 16,
503 | height: 16,
504 | position: "relative",
505 | zIndex: 10,
506 | },
507 | buttonStyle2: {
508 | backgroundColor: "transparent",
509 | flexDirection: "row",
510 | padding: 0,
511 | alignItems: "center",
512 | justifyContent: "center",
513 | width: 12,
514 | height: 12,
515 | position: "relative",
516 | zIndex: 10,
517 | },
518 | buttonStyle1: {
519 | backgroundColor: "transparent",
520 | borderColor: "none",
521 | borderWidth: 0,
522 | padding: 0,
523 | alignItems: "center",
524 | justifyContent: "center",
525 | width: 24,
526 | height: 24,
527 | position: "relative",
528 | },
529 | buttonStyle: {
530 | backgroundColor: "transparent",
531 | color: "#6B7280",
532 | borderWidth: 0,
533 | flexDirection: "row",
534 | alignItems: "center",
535 | justifyContent: "center",
536 | width: 12,
537 | height: 12,
538 | position: "relative",
539 | },
540 | expanded: {
541 | transform: [{ rotate: "90deg" }],
542 | },
543 | collapsed: {
544 | transform: [{ rotate: "0deg" }],
545 | },
546 | minWidthWrapper: {
547 | minWidth: 200,
548 | fontSize: 12,
549 | flexDirection: "row",
550 | flexWrap: "wrap",
551 | width: "100%",
552 | },
553 | fullWidthMarginRight: {
554 | position: "relative",
555 | width: "100%",
556 | marginRight: 1,
557 | },
558 | flexRowItemsCenterGap: {
559 | flexDirection: "row",
560 | alignItems: "center",
561 | justifyContent: "space-between",
562 | padding: 4,
563 | },
564 | expanderButton: {
565 | flexDirection: "row",
566 | alignItems: "center",
567 | height: 24,
568 | backgroundColor: "transparent",
569 | borderWidth: 0,
570 | padding: 2,
571 | },
572 | textGray500: {
573 | color: "#6B7280",
574 | fontSize: 12,
575 | marginLeft: 4,
576 | },
577 | flexRowGapItemsCenter: {
578 | flexDirection: "row",
579 | alignItems: "center",
580 | justifyContent: "space-between",
581 | padding: 4,
582 | },
583 | singleEntryContainer: {
584 | marginLeft: 12,
585 | paddingLeft: 16,
586 | borderLeftWidth: 2,
587 | borderColor: "#D1D5DB",
588 | },
589 | multiEntryContainer: {
590 | marginLeft: 12,
591 | paddingLeft: 16,
592 | borderLeftWidth: 2,
593 | borderColor: "#D1D5DB",
594 | },
595 | relativeOutlineNone: {
596 | position: "relative",
597 | },
598 | pageExpanderButton: {
599 | flexDirection: "row",
600 | alignItems: "center",
601 | backgroundColor: "transparent",
602 | borderWidth: 0,
603 | padding: 0,
604 | },
605 | entriesContainer: {
606 | marginLeft: 12,
607 | paddingLeft: 16,
608 | borderLeftWidth: 2,
609 | borderColor: "#D1D5DB",
610 | },
611 | flexRowGapFullWidth: {
612 | flexDirection: "row",
613 | width: "100%",
614 | alignItems: "center",
615 | marginVertical: 6,
616 | lineHeight: 44,
617 | },
618 | text344054: {
619 | color: "#344054",
620 | height: "100%",
621 | marginRight: 4,
622 | },
623 | inputContainer: {
624 | flexDirection: "row",
625 | justifyContent: "space-between",
626 | borderWidth: 0,
627 | height: 28,
628 | margin: 2,
629 | paddingVertical: 4,
630 | paddingLeft: 8,
631 | paddingRight: 6,
632 | borderRadius: 4,
633 | backgroundColor: "#EAECF0",
634 | flex: 1,
635 | },
636 | textNumber: {
637 | color: "#6938EF",
638 | },
639 | textInput: {
640 | flex: 1,
641 | marginRight: 8,
642 | paddingBottom: 2,
643 | paddingTop: 2,
644 | },
645 | textString: {},
646 | numberInputButtons: {
647 | flexDirection: "row",
648 | },
649 | touchableButton: {
650 | width: 24,
651 | },
652 | booleanContainer: {
653 | flexDirection: "row",
654 | alignItems: "center",
655 | padding: 6,
656 | borderRadius: 4,
657 | backgroundColor: "#F3F4F6",
658 | flex: 1,
659 | },
660 | booleanText: {
661 | marginLeft: 8,
662 | color: "#6938EF",
663 | },
664 | displayValueText: {
665 | flex: 1,
666 | color: "#6938EF",
667 | height: "100%",
668 | },
669 | });
670 |
--------------------------------------------------------------------------------
/app/dev-tools-bubble/_components/devtools/MutationButton.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Mutation } from "@tanstack/react-query";
3 | import { TouchableOpacity, Text, View, StyleSheet } from "react-native";
4 | import { CheckCircle, LoadingCircle, PauseCircle, XCircle } from "./svgs";
5 | import { getMutationStatusColors } from "../_util/mutationStatusToColorClass";
6 | import { displayValue } from "./displayValue";
7 | interface Props {
8 | mutation: Mutation;
9 | setSelected: React.Dispatch<
10 | React.SetStateAction | undefined>
11 | >;
12 | selected: Mutation | undefined;
13 | }
14 | export default function MutationButton({
15 | mutation,
16 | setSelected,
17 | selected,
18 | }: Props) {
19 | const mutationKey = mutation.options.mutationKey
20 | ? JSON.stringify(displayValue(mutation.options.mutationKey, false)) + " - "
21 | : "";
22 | const submittedAt = new Date(mutation.state.submittedAt).toLocaleString();
23 | const value = `${mutationKey}${submittedAt}`;
24 |
25 | const { backgroundColor, textColor } = getMutationStatusColors({
26 | isPaused: mutation.state.isPaused,
27 | status: mutation.state.status,
28 | });
29 | return (
30 | setSelected(mutation === selected ? undefined : mutation)}
32 | style={[
33 | styles.button,
34 | selected?.mutationId === mutation.mutationId && styles.selected,
35 | ]}
36 | >
37 |
38 | {mutation.state.isPaused && }
39 | {mutation.state.status === "success" && }
40 | {mutation.state.status === "error" && }
41 | {mutation.state.status === "pending" && }
42 |
43 | {value}
44 |
45 | );
46 | }
47 |
48 | const styles = StyleSheet.create({
49 | button: {
50 | flexDirection: "row",
51 | alignItems: "center",
52 | justifyContent: "flex-start",
53 | borderBottomWidth: 1,
54 | borderBottomColor: "#d0d5dd",
55 | backgroundColor: "white",
56 | },
57 | selected: {
58 | backgroundColor: "#eaecf0",
59 | },
60 | iconContainer: {
61 | padding: 8,
62 | paddingVertical: 6,
63 | },
64 | text: {
65 | marginLeft: 8,
66 | fontSize: 12,
67 | minWidth: 18,
68 | },
69 | });
70 |
--------------------------------------------------------------------------------
/app/dev-tools-bubble/_components/devtools/MutationDetails.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Mutation } from "@tanstack/react-query";
3 | import { View, Text, ScrollView, StyleSheet } from "react-native";
4 | import { displayValue } from "./displayValue";
5 | import MutationDetailsChips from "./MutationDetailsChips";
6 |
7 | interface Props {
8 | selectedMutation: Mutation | undefined;
9 | }
10 |
11 | export default function MutationDetails({ selectedMutation }: Props) {
12 | if (selectedMutation === undefined) {
13 | return null;
14 | }
15 |
16 | const submittedAt = new Date(
17 | selectedMutation.state.submittedAt
18 | ).toLocaleTimeString();
19 |
20 | return (
21 |
22 |
23 | Mutation Details
24 |
25 |
26 |
27 | {`${
28 | selectedMutation.options.mutationKey
29 | ? displayValue(selectedMutation.options.mutationKey, true)
30 | : "No mutationKey found"
31 | }`}
32 |
33 |
34 |
35 |
36 | Submitted At:
37 | {submittedAt}
38 |
39 |
40 | );
41 | }
42 |
43 | const styles = StyleSheet.create({
44 | container: {
45 | minWidth: 200,
46 | fontSize: 12,
47 | backgroundColor: "#FFFFFF",
48 | borderRadius: 4,
49 | },
50 | mutationDetailsText: {
51 | textAlign: "left",
52 | backgroundColor: "#EAECF0",
53 | padding: 8,
54 | fontWeight: "500",
55 | },
56 | flexRow: {
57 | flexDirection: "row",
58 | justifyContent: "space-between",
59 | padding: 8,
60 | borderBottomWidth: 1,
61 | borderBottomColor: "#F3F4F6",
62 | },
63 | justifyBetween: {
64 | justifyContent: "space-between",
65 | },
66 | p1: {
67 | padding: 8,
68 | },
69 | flex1: {
70 | flex: 1,
71 | },
72 | flexWrap: {
73 | flexWrap: "wrap",
74 | alignItems: "center",
75 | marginRight: 8,
76 | },
77 | bgEAECF0: {
78 | backgroundColor: "#EAECF0",
79 | },
80 | });
81 |
--------------------------------------------------------------------------------
/app/dev-tools-bubble/_components/devtools/MutationDetailsChips.tsx:
--------------------------------------------------------------------------------
1 | import { Mutation } from "@tanstack/react-query";
2 | import React from "react";
3 | import { Text, View, StyleSheet } from "react-native";
4 |
5 | const backgroundColors = {
6 | fresh: "#D1FADF", // Green
7 | stale: "#FEF0C7", // Yellow
8 | fetching: "#D1E9FF", // Blue
9 | paused: "#EBE9FE", // Indigo
10 | inactive: "#F2F4F7", // Grey
11 | };
12 |
13 | const borderColors = {
14 | fresh: "#32D583", // Green
15 | stale: "#FDB022", // Yellow
16 | fetching: "#53B1FD", // Blue
17 | paused: "#9B8AFB", // Indigo
18 | inactive: "#344054", // Grey
19 | };
20 |
21 | const textColors = {
22 | fresh: "#027A48", // Green
23 | stale: "#B54708", // Yellow
24 | fetching: "#175CD3", // Blue
25 | paused: "#5925DC", // Indigo
26 | inactive: "#344054", // Grey
27 | };
28 | interface Props {
29 | status: Mutation["state"]["status"];
30 | }
31 | export default function QueryDetailsChip({ status }: Props) {
32 | const statusToColor =
33 | status === "pending"
34 | ? "fetching"
35 | : status === "idle"
36 | ? "inactive"
37 | : status === "error"
38 | ? "stale"
39 | : "fresh";
40 | const backgroundColor = backgroundColors[statusToColor];
41 | const borderColor = borderColors[statusToColor];
42 | const textColor = textColors[statusToColor];
43 |
44 | return (
45 |
46 | {status}
47 |
48 | );
49 | }
50 | const styles = StyleSheet.create({
51 | container: {
52 | padding: 8,
53 | borderWidth: 1,
54 | borderRadius: 4,
55 | margin: 4,
56 | },
57 | text: {
58 | fontSize: 12,
59 | },
60 | });
61 |
--------------------------------------------------------------------------------
/app/dev-tools-bubble/_components/devtools/MutationInformation.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Mutation } from "@tanstack/react-query";
3 | import DataExplorer from "./Explorer";
4 | import { ScrollView, Text, View, StyleSheet } from "react-native";
5 | import MutationDetails from "./MutationDetails";
6 |
7 | interface Props {
8 | selectedMutation: Mutation | undefined;
9 | }
10 |
11 | export default function MutationInformation({ selectedMutation }: Props) {
12 | return (
13 |
17 |
18 |
19 |
20 |
21 | Variables Details
22 |
23 |
28 |
29 |
30 |
31 | Context Details
32 |
33 |
38 |
39 |
40 |
41 | Data Explorer
42 |
43 |
48 |
49 |
50 |
51 | Mutations Explorer
52 |
53 |
58 |
59 |
60 |
61 | );
62 | }
63 |
64 | const styles = StyleSheet.create({
65 | flex1: {
66 | flex: 1,
67 | },
68 | scrollContent: {
69 | paddingBottom: 16,
70 | },
71 | section: {
72 | marginBottom: 12,
73 | },
74 | textHeader: {
75 | textAlign: "left",
76 | backgroundColor: "#EAECF0",
77 | padding: 8,
78 | width: "100%",
79 | fontSize: 12,
80 | fontWeight: "500",
81 | },
82 | padding: {
83 | padding: 8,
84 | backgroundColor: "#FAFAFA",
85 | },
86 | });
87 |
--------------------------------------------------------------------------------
/app/dev-tools-bubble/_components/devtools/MutationsList.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useRef } from "react";
2 | import {
3 | ScrollView,
4 | View,
5 | StyleSheet,
6 | PanResponder,
7 | Animated,
8 | Dimensions,
9 | } from "react-native";
10 | import { Mutation } from "@tanstack/react-query";
11 | import MutationButton from "./MutationButton";
12 | import MutationInformation from "./MutationInformation";
13 | import useAllMutations from "../_hooks/useAllMutations";
14 |
15 | interface Props {
16 | selectedMutation: Mutation | undefined;
17 | setSelectedMutation: React.Dispatch<
18 | React.SetStateAction | undefined>
19 | >;
20 | }
21 |
22 | export default function MutationsList({
23 | selectedMutation,
24 | setSelectedMutation,
25 | }: Props) {
26 | const { mutations: allmutations } = useAllMutations();
27 |
28 | // Height management for resizable mutation information panel
29 | const screenHeight = Dimensions.get("window").height;
30 | const defaultInfoHeight = screenHeight * 0.4; // 40% of screen height
31 | const minInfoHeight = 150;
32 | const maxInfoHeight = screenHeight * 0.7; // 70% of screen height
33 |
34 | const infoHeightAnim = useRef(new Animated.Value(defaultInfoHeight)).current;
35 | const [currentInfoHeight, setCurrentInfoHeight] = useState(defaultInfoHeight);
36 | const currentInfoHeightRef = useRef(defaultInfoHeight);
37 |
38 | // Pan responder for dragging the mutation information panel
39 | const infoPanResponder = useRef(
40 | PanResponder.create({
41 | onMoveShouldSetPanResponder: (evt, gestureState) => {
42 | return (
43 | Math.abs(gestureState.dy) > Math.abs(gestureState.dx) &&
44 | Math.abs(gestureState.dy) > 10
45 | );
46 | },
47 | onPanResponderGrant: () => {
48 | infoHeightAnim.stopAnimation((value) => {
49 | setCurrentInfoHeight(value);
50 | currentInfoHeightRef.current = value;
51 | infoHeightAnim.setValue(value);
52 | });
53 | },
54 | onPanResponderMove: (evt, gestureState) => {
55 | // Use the ref value which is always current
56 | const newHeight = currentInfoHeightRef.current - gestureState.dy;
57 | const clampedHeight = Math.max(
58 | minInfoHeight,
59 | Math.min(maxInfoHeight, newHeight)
60 | );
61 | infoHeightAnim.setValue(clampedHeight);
62 | },
63 | onPanResponderRelease: (evt, gestureState) => {
64 | const finalHeight = Math.max(
65 | minInfoHeight,
66 | Math.min(
67 | maxInfoHeight,
68 | currentInfoHeightRef.current - gestureState.dy
69 | )
70 | );
71 | setCurrentInfoHeight(finalHeight);
72 | currentInfoHeightRef.current = finalHeight;
73 |
74 | Animated.timing(infoHeightAnim, {
75 | toValue: finalHeight,
76 | duration: 200,
77 | useNativeDriver: false,
78 | }).start(() => {
79 | // Ensure the animated value and state are perfectly synced after animation
80 | infoHeightAnim.setValue(finalHeight);
81 | setCurrentInfoHeight(finalHeight);
82 | currentInfoHeightRef.current = finalHeight;
83 | });
84 | },
85 | })
86 | ).current;
87 |
88 | return (
89 |
90 |
91 | {allmutations.map((mutation, inex) => {
92 | return (
93 |
99 | );
100 | })}
101 |
102 | {selectedMutation && (
103 |
106 | {/* Drag handle for resizing */}
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 | )}
115 |
116 | );
117 | }
118 |
119 | const styles = StyleSheet.create({
120 | container: {
121 | flex: 1,
122 | flexDirection: "column",
123 | width: "100%",
124 | },
125 | scrollView: {
126 | flex: 1,
127 | flexDirection: "column",
128 | },
129 | mutationInfo: {
130 | borderTopWidth: 2,
131 | borderTopColor: "#d0d5dd",
132 | backgroundColor: "#ffffff",
133 | },
134 | dragHandle: {
135 | height: 20,
136 | justifyContent: "center",
137 | alignItems: "center",
138 | backgroundColor: "#f8f9fa",
139 | borderBottomWidth: 1,
140 | borderBottomColor: "#e5e7eb",
141 | },
142 | dragIndicator: {
143 | width: 50,
144 | height: 4,
145 | backgroundColor: "#98a2b3",
146 | borderRadius: 2,
147 | opacity: 0.8,
148 | },
149 | mutationInfoContent: {
150 | flex: 1,
151 | },
152 | });
153 |
--------------------------------------------------------------------------------
/app/dev-tools-bubble/_components/devtools/NetworkToggleButton.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { TouchableOpacity, StyleSheet, View } from "react-native";
3 | import { Svg, Path } from "react-native-svg";
4 |
5 | interface NetworkToggleButtonProps {
6 | isOffline: boolean;
7 | onToggle: () => void;
8 | }
9 |
10 | const NetworkToggleButton: React.FC = ({
11 | isOffline,
12 | onToggle,
13 | }) => {
14 | return (
15 |
25 | {isOffline ? : }
26 |
27 | );
28 | };
29 |
30 | // Wifi icon component
31 | const WifiIcon = () => (
32 |
46 | );
47 |
48 | // Offline icon component
49 | const OfflineIcon = () => (
50 |
63 | );
64 |
65 | const styles = StyleSheet.create({
66 | button: {
67 | width: 24,
68 | height: 24,
69 | borderRadius: 4,
70 | backgroundColor: "#f9fafb",
71 | justifyContent: "center",
72 | alignItems: "center",
73 | borderWidth: 1,
74 | borderColor: "#e5e7eb",
75 | },
76 | offlineButton: {
77 | backgroundColor: "#fee2e2", // Light red background for offline state
78 | borderColor: "#fca5a5",
79 | },
80 | });
81 |
82 | export default NetworkToggleButton;
83 |
--------------------------------------------------------------------------------
/app/dev-tools-bubble/_components/devtools/QueriesList.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useRef } from "react";
2 | import { Query } from "@tanstack/react-query";
3 | import {
4 | FlatList,
5 | View,
6 | StyleSheet,
7 | SafeAreaView,
8 | Text,
9 | PanResponder,
10 | Animated,
11 | Dimensions,
12 | } from "react-native";
13 | import QueryRow from "./QueryRow";
14 | import useAllQueries from "../_hooks/useAllQueries";
15 | import QueryInformation from "./QueryInformation";
16 |
17 | interface Props {
18 | selectedQuery: Query | undefined;
19 | setSelectedQuery: React.Dispatch>;
20 | }
21 |
22 | export default function QueriesList({
23 | selectedQuery,
24 | setSelectedQuery,
25 | }: Props) {
26 | // Holds all queries
27 | const allQueries = useAllQueries();
28 |
29 | // Height management for resizable query information panel
30 | const screenHeight = Dimensions.get("window").height;
31 | const defaultInfoHeight = screenHeight * 0.4; // 40% of screen height
32 | const minInfoHeight = 150;
33 | const maxInfoHeight = screenHeight * 0.7; // 70% of screen height
34 |
35 | const infoHeightAnim = useRef(new Animated.Value(defaultInfoHeight)).current;
36 | const [currentInfoHeight, setCurrentInfoHeight] = useState(defaultInfoHeight);
37 | const currentInfoHeightRef = useRef(defaultInfoHeight);
38 |
39 | // Pan responder for dragging the query information panel
40 | const infoPanResponder = useRef(
41 | PanResponder.create({
42 | onMoveShouldSetPanResponder: (evt, gestureState) => {
43 | return (
44 | Math.abs(gestureState.dy) > Math.abs(gestureState.dx) &&
45 | Math.abs(gestureState.dy) > 10
46 | );
47 | },
48 | onPanResponderGrant: () => {
49 | infoHeightAnim.stopAnimation((value) => {
50 | setCurrentInfoHeight(value);
51 | currentInfoHeightRef.current = value;
52 | infoHeightAnim.setValue(value);
53 | });
54 | },
55 | onPanResponderMove: (evt, gestureState) => {
56 | // Use the ref value which is always current
57 | const newHeight = currentInfoHeightRef.current - gestureState.dy;
58 | const clampedHeight = Math.max(
59 | minInfoHeight,
60 | Math.min(maxInfoHeight, newHeight)
61 | );
62 | infoHeightAnim.setValue(clampedHeight);
63 | },
64 | onPanResponderRelease: (evt, gestureState) => {
65 | const finalHeight = Math.max(
66 | minInfoHeight,
67 | Math.min(
68 | maxInfoHeight,
69 | currentInfoHeightRef.current - gestureState.dy
70 | )
71 | );
72 | setCurrentInfoHeight(finalHeight);
73 | currentInfoHeightRef.current = finalHeight;
74 |
75 | Animated.timing(infoHeightAnim, {
76 | toValue: finalHeight,
77 | duration: 200,
78 | useNativeDriver: false,
79 | }).start(() => {
80 | // Ensure the animated value and state are perfectly synced after animation
81 | infoHeightAnim.setValue(finalHeight);
82 | setCurrentInfoHeight(finalHeight);
83 | currentInfoHeightRef.current = finalHeight;
84 | });
85 | },
86 | })
87 | ).current;
88 |
89 | // Function to handle query selection
90 | const handleQuerySelect = (query: Query) => {
91 | // If deselecting (i.e., clicking the same query), just update the state
92 | if (query === selectedQuery) {
93 | setSelectedQuery(undefined);
94 | return;
95 | }
96 | setSelectedQuery(query); // Update the selected query
97 | };
98 |
99 | const renderItem = ({ item }: { item: Query }) => (
100 |
105 | );
106 |
107 | return (
108 |
109 |
110 | {allQueries.length > 0 ? (
111 |
115 | `${JSON.stringify(item.queryKey)}-${index}`
116 | }
117 | contentContainerStyle={styles.listContent}
118 | />
119 | ) : (
120 |
121 | No queries found
122 |
123 | )}
124 |
125 | {selectedQuery && (
126 |
129 | {/* Drag handle for resizing */}
130 |
131 |
132 |
133 |
134 |
138 |
139 |
140 | )}
141 |
142 | );
143 | }
144 |
145 | const styles = StyleSheet.create({
146 | container: {
147 | flex: 1,
148 | width: "100%",
149 | },
150 | listContainer: {
151 | flex: 1,
152 | width: "100%",
153 | backgroundColor: "#ffffff",
154 | },
155 | listContent: {
156 | flexGrow: 1,
157 | },
158 | emptyContainer: {
159 | flex: 1,
160 | justifyContent: "center",
161 | alignItems: "center",
162 | padding: 20,
163 | },
164 | emptyText: {
165 | color: "#6b7280",
166 | fontSize: 16,
167 | },
168 | queryInformation: {
169 | borderTopWidth: 2,
170 | borderTopColor: "#d0d5dd",
171 | backgroundColor: "#ffffff",
172 | },
173 | dragHandle: {
174 | height: 20,
175 | justifyContent: "center",
176 | alignItems: "center",
177 | backgroundColor: "#f8f9fa",
178 | borderBottomWidth: 1,
179 | borderBottomColor: "#e5e7eb",
180 | },
181 | dragIndicator: {
182 | width: 50,
183 | height: 4,
184 | backgroundColor: "#98a2b3",
185 | borderRadius: 2,
186 | opacity: 0.8,
187 | },
188 | queryInfoContent: {
189 | flex: 1,
190 | },
191 | });
192 |
--------------------------------------------------------------------------------
/app/dev-tools-bubble/_components/devtools/QueryActions.tsx:
--------------------------------------------------------------------------------
1 | import { Query, QueryKey, useQueryClient } from "@tanstack/react-query";
2 | import React from "react";
3 | import ActionButton from "./ActionButton";
4 | import { getQueryStatusLabel } from "../_util/getQueryStatusLabel";
5 | import triggerLoading from "../_util/actions/triggerLoading";
6 | import refetch from "../_util/actions/refetch";
7 | import reset from "../_util/actions/reset";
8 | import remove from "../_util/actions/remove";
9 | import invalidate from "../_util/actions/invalidate";
10 | import triggerError from "../_util/actions/triggerError";
11 | import { View, Text, StyleSheet } from "react-native";
12 |
13 | interface Props {
14 | setSelectedQuery: React.Dispatch<
15 | React.SetStateAction | undefined>
16 | >;
17 | query: Query | undefined;
18 | }
19 | export default function QueryActions({ query, setSelectedQuery }: Props) {
20 | const queryClient = useQueryClient();
21 | if (query === undefined) {
22 | return null;
23 | }
24 | const queryStatus = query.state.status;
25 | return (
26 |
27 | Actions
28 | {
31 | refetch({
32 | query,
33 | });
34 | }}
35 | bgColorClass="btnRefetch"
36 | text="Refetch"
37 | textColorClass="btnRefetch"
38 | />
39 | {
42 | invalidate({ query, queryClient });
43 | }}
44 | bgColorClass="btnInvalidate"
45 | text="Invalidate"
46 | textColorClass="btnInvalidate"
47 | />
48 | {
51 | reset({ queryClient, query });
52 | }}
53 | bgColorClass="btnReset"
54 | text="Reset"
55 | textColorClass="btnReset"
56 | />
57 | {
60 | remove({ queryClient, query });
61 | setSelectedQuery(undefined);
62 | }}
63 | bgColorClass="btnRemove"
64 | text="Remove"
65 | textColorClass="btnRemove"
66 | />
67 | {
70 | triggerLoading({ query });
71 | }}
72 | bgColorClass="btnTriggerLoading"
73 | text={
74 | query.state.data === undefined ? "Restore Loading" : "Trigger Loading"
75 | }
76 | textColorClass="btnTriggerLoading"
77 | />
78 | {
81 | triggerError({ query, queryClient });
82 | }}
83 | bgColorClass="btnTriggerLoadiError"
84 | text={queryStatus === "error" ? "Restore" : "Trigger Error"}
85 | textColorClass="btnTriggerLoadiError"
86 | />
87 |
88 | );
89 | }
90 | const styles = StyleSheet.create({
91 | container: {
92 | minWidth: 50,
93 | fontSize: 12,
94 | flexDirection: "row",
95 | flexWrap: "wrap",
96 | backgroundColor: "#FFFFFF",
97 | borderRadius: 4,
98 | gap: 8,
99 | padding: 8,
100 | },
101 | headerText: {
102 | textAlign: "left",
103 | backgroundColor: "#EAECF0",
104 | padding: 8,
105 | width: "100%",
106 | fontWeight: "500",
107 | marginBottom: 8,
108 | },
109 | });
110 |
--------------------------------------------------------------------------------
/app/dev-tools-bubble/_components/devtools/QueryDetails.tsx:
--------------------------------------------------------------------------------
1 | import { Query, QueryKey } from "@tanstack/react-query";
2 | import React from "react";
3 | import QueryDetailsChip from "./QueryDetailsChip";
4 | import { View, Text, ScrollView, StyleSheet } from "react-native";
5 | import { displayValue } from "./displayValue";
6 |
7 | interface Props {
8 | query: Query | undefined;
9 | }
10 | export default function QueryDetails({ query }: Props) {
11 | if (query === undefined) {
12 | return null;
13 | }
14 | // Convert the timestamp to a Date object and format it
15 | const lastUpdated = new Date(query.state.dataUpdatedAt).toLocaleTimeString();
16 |
17 | return (
18 |
19 | Query Details
20 |
21 |
22 |
23 | {displayValue(query.queryKey, true)}
24 |
25 |
26 |
27 |
28 |
29 | Observers:
30 | {`${query.getObserversCount()}`}
31 |
32 |
33 | Last Updated:
34 | {`${lastUpdated}`}
35 |
36 |
37 | );
38 | }
39 | const styles = StyleSheet.create({
40 | minWidth: {
41 | minWidth: 200,
42 | fontSize: 12,
43 | backgroundColor: "#FFFFFF",
44 | borderRadius: 4,
45 | },
46 | headerText: {
47 | textAlign: "left",
48 | backgroundColor: "#EAECF0",
49 | padding: 8,
50 | fontWeight: "500",
51 | },
52 | row: {
53 | flexDirection: "row",
54 | justifyContent: "space-between",
55 | padding: 8,
56 | borderBottomWidth: 1,
57 | borderBottomColor: "#F3F4F6",
58 | },
59 | flexOne: {
60 | flex: 1,
61 | },
62 | queryKeyText: {
63 | flexWrap: "wrap",
64 | alignItems: "center",
65 | marginRight: 8,
66 | },
67 | });
68 |
--------------------------------------------------------------------------------
/app/dev-tools-bubble/_components/devtools/QueryDetailsChip.tsx:
--------------------------------------------------------------------------------
1 | import { Query } from "@tanstack/react-query";
2 | import React from "react";
3 | import { getQueryStatusLabel } from "../_util/getQueryStatusLabel";
4 | import { Text, View, StyleSheet } from "react-native";
5 | interface Props {
6 | query: Query;
7 | }
8 | const backgroundColors = {
9 | fresh: "#D1FADF", // Green
10 | stale: "#FEF0C7", // Yellow
11 | fetching: "#D1E9FF", // Blue
12 | paused: "#EBE9FE", // Indigo
13 | noObserver: "#F2F4F7", // Grey
14 | };
15 |
16 | const borderColors = {
17 | fresh: "#32D583", // Green
18 | stale: "#FDB022", // Yellow
19 | fetching: "#53B1FD", // Blue
20 | paused: "#9B8AFB", // Indigo
21 | noObserver: "#344054", // Grey
22 | };
23 |
24 | const textColors = {
25 | fresh: "#027A48", // Green
26 | stale: "#B54708", // Yellow
27 | fetching: "#175CD3", // Blue
28 | paused: "#5925DC", // Indigo
29 | noObserver: "#344054", // Grey
30 | };
31 | type QueryStatus = "fresh" | "stale" | "fetching" | "paused" | "noObserver";
32 |
33 | export default function QueryDetailsChip({ query }: Props) {
34 | const status = getQueryStatusLabel(query) as QueryStatus;
35 | const backgroundColor = backgroundColors[status];
36 | const borderColor = borderColors[status];
37 | const textColor = textColors[status];
38 |
39 | return (
40 |
41 | {status}
42 |
43 | );
44 | }
45 | const styles = StyleSheet.create({
46 | container: {
47 | padding: 8,
48 | borderWidth: 1,
49 | borderRadius: 4,
50 | margin: 4,
51 | },
52 | text: {
53 | fontSize: 12,
54 | },
55 | });
56 |
--------------------------------------------------------------------------------
/app/dev-tools-bubble/_components/devtools/QueryInformation.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Query } from "@tanstack/react-query";
3 | import QueryDetails from "./QueryDetails";
4 | import QueryActions from "./QueryActions";
5 | import DataExplorer from "./Explorer";
6 | import { View, Text, ScrollView, StyleSheet } from "react-native";
7 |
8 | interface Props {
9 | setSelectedQuery: React.Dispatch>;
10 | selectedQuery: Query | undefined;
11 | }
12 | export default function QueryInformation({
13 | selectedQuery,
14 | setSelectedQuery,
15 | }: Props) {
16 | return (
17 |
21 |
22 |
23 |
24 |
25 |
29 |
30 |
31 | Data Explorer
32 |
33 |
40 |
41 |
42 |
43 | Query Explorer
44 |
45 |
51 |
52 |
53 |
54 | );
55 | }
56 |
57 | const styles = StyleSheet.create({
58 | flexOne: {
59 | flex: 1,
60 | },
61 | scrollContent: {
62 | paddingBottom: 16,
63 | },
64 | section: {
65 | marginBottom: 12,
66 | },
67 | headerText: {
68 | textAlign: "left",
69 | backgroundColor: "#EAECF0",
70 | padding: 8,
71 | width: "100%",
72 | fontSize: 12,
73 | fontWeight: "500",
74 | },
75 | contentView: {
76 | padding: 8,
77 | backgroundColor: "#FAFAFA",
78 | },
79 | });
80 |
--------------------------------------------------------------------------------
/app/dev-tools-bubble/_components/devtools/QueryRow.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { View, Text, TouchableOpacity, StyleSheet } from "react-native";
3 | import { Query } from "@tanstack/react-query";
4 | import { getQueryStatusLabel } from "../_util/getQueryStatusLabel";
5 | import { displayValue } from "./displayValue";
6 |
7 | interface QueryRowProps {
8 | query: Query;
9 | isSelected: boolean;
10 | onSelect: (query: Query) => void;
11 | }
12 |
13 | const QueryRow: React.FC = ({ query, isSelected, onSelect }) => {
14 | // Map status to color names
15 | const getStatusColor = (
16 | status: string
17 | ): "green" | "yellow" | "gray" | "blue" | "purple" | "red" => {
18 | switch (status) {
19 | case "fresh":
20 | return "green";
21 | case "stale":
22 | case "inactive":
23 | return "yellow";
24 | case "fetching":
25 | return "blue";
26 | case "paused":
27 | return "purple";
28 | default:
29 | return "gray";
30 | }
31 | };
32 |
33 | // Map color names to actual color values
34 | const getColorValue = (
35 | colorName: "green" | "yellow" | "gray" | "blue" | "purple" | "red",
36 | shade: "200" | "300" | "700" | "800" | "900"
37 | ): string => {
38 | const colors: Record<
39 | "green" | "yellow" | "gray" | "blue" | "purple" | "red",
40 | Record<"200" | "300" | "700" | "800" | "900", string>
41 | > = {
42 | green: {
43 | "200": "#bbf7d0",
44 | "300": "#86efac",
45 | "700": "#15803d",
46 | "800": "#166534",
47 | "900": "#14532d",
48 | },
49 | yellow: {
50 | "200": "#fef08a",
51 | "300": "#fde047",
52 | "700": "#a16207",
53 | "800": "#854d0e",
54 | "900": "#713f12",
55 | },
56 | gray: {
57 | "200": "#e5e7eb",
58 | "300": "#d1d5db",
59 | "700": "#374151",
60 | "800": "#1f2937",
61 | "900": "#111827",
62 | },
63 | blue: {
64 | "200": "#bfdbfe",
65 | "300": "#93c5fd",
66 | "700": "#1d4ed8",
67 | "800": "#1e40af",
68 | "900": "#1e3a8a",
69 | },
70 | purple: {
71 | "200": "#e9d5ff",
72 | "300": "#d8b4fe",
73 | "700": "#7e22ce",
74 | "800": "#6b21a8",
75 | "900": "#581c87",
76 | },
77 | red: {
78 | "200": "#fecaca",
79 | "300": "#fca5a5",
80 | "700": "#b91c1c",
81 | "800": "#991b1b",
82 | "900": "#7f1d1d",
83 | },
84 | };
85 |
86 | return colors[colorName][shade];
87 | };
88 |
89 | const status = getQueryStatusLabel(query);
90 | const statusColor = getStatusColor(status);
91 | const observerCount = query.getObserversCount();
92 | const isDisabled = query.isDisabled();
93 | const queryHash = displayValue(query.queryKey, false);
94 |
95 | // Get background and text colors for observer count based on status
96 | const getObserverCountStyles = () => {
97 | if (statusColor === "gray") {
98 | return {
99 | backgroundColor: getColorValue(statusColor, "200"),
100 | color: getColorValue(statusColor, "700"),
101 | };
102 | }
103 |
104 | return {
105 | backgroundColor: getColorValue(statusColor, "200"),
106 | color: getColorValue(statusColor, "800"),
107 | };
108 | };
109 |
110 | return (
111 | onSelect(query)}
114 | activeOpacity={0.7}
115 | accessibilityLabel={`Query key ${queryHash}`}
116 | >
117 | {/* Observer count badge */}
118 |
119 |
125 | {observerCount}
126 |
127 |
128 |
129 | {/* Query hash/key */}
130 |
131 | {queryHash}
132 |
133 |
134 | {/* Disabled indicator */}
135 | {isDisabled && (
136 |
137 | disabled
138 |
139 | )}
140 |
141 | );
142 | };
143 |
144 | const styles = StyleSheet.create({
145 | queryRow: {
146 | flexDirection: "row",
147 | alignItems: "stretch",
148 | borderBottomWidth: 1,
149 | borderBottomColor: "#e5e7eb",
150 | backgroundColor: "#ffffff",
151 | },
152 | selectedQueryRow: {
153 | backgroundColor: "#f3f4f6",
154 | },
155 | observerCount: {
156 | width: 32,
157 | justifyContent: "center",
158 | alignItems: "center",
159 | marginRight: 0,
160 | },
161 | observerCountText: {
162 | fontSize: 12,
163 | fontWeight: "600",
164 | fontVariant: ["tabular-nums"],
165 | },
166 | queryHash: {
167 | flex: 1,
168 | fontFamily: "monospace",
169 | fontSize: 14,
170 | color: "#1f2937",
171 | paddingVertical: 8,
172 | paddingHorizontal: 12,
173 | textAlignVertical: "center",
174 | },
175 | disabledIndicator: {
176 | backgroundColor: "#f3f4f6",
177 | borderRadius: 4,
178 | paddingHorizontal: 6,
179 | paddingVertical: 2,
180 | marginLeft: 8,
181 | alignSelf: "center",
182 | },
183 | disabledText: {
184 | fontSize: 12,
185 | color: "#6b7280",
186 | },
187 | });
188 |
189 | export default QueryRow;
190 |
--------------------------------------------------------------------------------
/app/dev-tools-bubble/_components/devtools/QueryStatus.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { View, Text, TouchableOpacity, StyleSheet } from "react-native";
3 |
4 | interface QueryStatusProps {
5 | label: string;
6 | color: "green" | "yellow" | "gray" | "blue" | "purple" | "red";
7 | count: number;
8 | showLabel?: boolean;
9 | }
10 |
11 | type ColorName = "green" | "yellow" | "gray" | "blue" | "purple" | "red";
12 | type ColorShade = "100" | "200" | "300" | "400" | "500" | "700" | "900";
13 |
14 | const QueryStatus: React.FC = ({
15 | label,
16 | color,
17 | count,
18 | showLabel = true,
19 | }) => {
20 | const [isHovered, setIsHovered] = useState(false);
21 |
22 | // Map color names to actual color values
23 | const getColorValue = (colorName: ColorName, shade: ColorShade): string => {
24 | const colors: Record> = {
25 | green: {
26 | "100": "#dcfce7",
27 | "200": "#bbf7d0",
28 | "300": "#86efac",
29 | "400": "#4ade80",
30 | "500": "#22c55e",
31 | "700": "#15803d",
32 | "900": "#14532d",
33 | },
34 | yellow: {
35 | "100": "#fef9c3",
36 | "200": "#fef08a",
37 | "300": "#fde047",
38 | "400": "#facc15",
39 | "500": "#eab308",
40 | "700": "#a16207",
41 | "900": "#713f12",
42 | },
43 | gray: {
44 | "100": "#f3f4f6",
45 | "200": "#e5e7eb",
46 | "300": "#d1d5db",
47 | "400": "#9ca3af",
48 | "500": "#6b7280",
49 | "700": "#374151",
50 | "900": "#111827",
51 | },
52 | blue: {
53 | "100": "#dbeafe",
54 | "200": "#bfdbfe",
55 | "300": "#93c5fd",
56 | "400": "#60a5fa",
57 | "500": "#3b82f6",
58 | "700": "#1d4ed8",
59 | "900": "#1e3a8a",
60 | },
61 | purple: {
62 | "100": "#f3e8ff",
63 | "200": "#e9d5ff",
64 | "300": "#d8b4fe",
65 | "400": "#c084fc",
66 | "500": "#a855f7",
67 | "700": "#7e22ce",
68 | "900": "#581c87",
69 | },
70 | red: {
71 | "100": "#fee2e2",
72 | "200": "#fecaca",
73 | "300": "#fca5a5",
74 | "400": "#f87171",
75 | "500": "#ef4444",
76 | "700": "#b91c1c",
77 | "900": "#7f1d1d",
78 | },
79 | };
80 |
81 | return colors[colorName]?.[shade] || "#000000";
82 | };
83 |
84 | return (
85 | setIsHovered(true)}
89 | onPressOut={() => setIsHovered(false)}
90 | activeOpacity={0.7}
91 | >
92 | {!showLabel && isHovered && (
93 |
94 | {label}
95 |
96 | )}
97 |
98 |
101 |
102 | {showLabel && {label}}
103 |
104 | 0 &&
108 | color !== "gray" && {
109 | backgroundColor: getColorValue(color, "100"),
110 | },
111 | ]}
112 | >
113 | 0 &&
117 | color !== "gray" && {
118 | color: getColorValue(color, "700"),
119 | },
120 | ]}
121 | >
122 | {count}
123 |
124 |
125 |
126 | );
127 | };
128 |
129 | const styles = StyleSheet.create({
130 | queryStatusTag: {
131 | flexDirection: "row",
132 | gap: 6,
133 | height: 26,
134 | backgroundColor: "#f9fafb",
135 | borderRadius: 4,
136 | padding: 4,
137 | paddingLeft: 6,
138 | alignItems: "center",
139 | fontWeight: "500",
140 | borderWidth: 1,
141 | borderColor: "#e5e7eb",
142 | position: "relative",
143 | },
144 | clickable: {
145 | // cursor: 'pointer', // This doesn't exist in React Native
146 | },
147 | dot: {
148 | width: 6,
149 | height: 6,
150 | borderRadius: 3,
151 | },
152 | label: {
153 | fontSize: 12,
154 | },
155 | countContainer: {
156 | fontSize: 12,
157 | paddingHorizontal: 5,
158 | alignItems: "center",
159 | justifyContent: "center",
160 | backgroundColor: "#e5e7eb",
161 | borderRadius: 2,
162 | height: 18,
163 | },
164 | count: {
165 | fontSize: 12,
166 | color: "#6b7280",
167 | fontVariant: ["tabular-nums"],
168 | },
169 | tooltip: {
170 | position: "absolute",
171 | zIndex: 1,
172 | backgroundColor: "#f9fafb",
173 | top: "100%",
174 | left: "50%",
175 | transform: [{ translateX: -50 }, { translateY: 8 }],
176 | padding: 2,
177 | paddingHorizontal: 8,
178 | borderRadius: 4,
179 | borderWidth: 1,
180 | borderColor: "#9ca3af",
181 | },
182 | tooltipText: {
183 | fontSize: 12,
184 | },
185 | });
186 |
187 | export default QueryStatus;
188 |
--------------------------------------------------------------------------------
/app/dev-tools-bubble/_components/devtools/QueryStatusCount.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { View, StyleSheet } from "react-native";
3 | import QueryStatus from "./QueryStatus";
4 | import useQueryStatusCounts from "../_hooks/useQueryStatusCounts";
5 |
6 | const QueryStatusCount: React.FC = () => {
7 | const { fresh, stale, fetching, paused, inactive } = useQueryStatusCounts();
8 |
9 | return (
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | );
18 | };
19 |
20 | const styles = StyleSheet.create({
21 | queryStatusContainer: {
22 | flexDirection: "row",
23 | flexWrap: "wrap",
24 | gap: 4,
25 | alignItems: "center",
26 | justifyContent: "center",
27 | paddingVertical: 2,
28 | paddingHorizontal: 4,
29 | },
30 | });
31 |
32 | export default QueryStatusCount;
33 |
--------------------------------------------------------------------------------
/app/dev-tools-bubble/_components/devtools/displayValue.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Displays a string regardless the type of the data
3 | * @param {unknown} value Value to be stringified
4 | * @param {boolean} beautify Formats json to multiline
5 | */
6 | export const displayValue = (value: unknown, beautify: boolean = false) => {
7 | const getCircularReplacer = () => {
8 | const seen = new WeakSet();
9 | return (key: string, value: any) => {
10 | if (typeof value === "object" && value !== null) {
11 | if (seen.has(value)) {
12 | return "[Circular]";
13 | }
14 | seen.add(value);
15 | }
16 | return value;
17 | };
18 | };
19 |
20 | return JSON.stringify(value, getCircularReplacer(), beautify ? 2 : undefined);
21 | };
22 |
--------------------------------------------------------------------------------
/app/dev-tools-bubble/context/CopyContext.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, useContext } from "react";
2 |
3 | export type ClipboardFunction = (text: string) => Promise;
4 |
5 | interface CopyContextType {
6 | onCopy?: ClipboardFunction;
7 | }
8 |
9 | export const CopyContext = createContext({});
10 |
11 | export const useCopy = () => useContext(CopyContext);
12 |
--------------------------------------------------------------------------------
/app/dev-tools-bubble/index.ts:
--------------------------------------------------------------------------------
1 | export { DevToolsBubble } from "./DevToolsBubble";
2 |
--------------------------------------------------------------------------------
/assets/fonts/SpaceMono-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LovesWorking/RN-Dev-Tools-Example/d5cba14f5fd0a92f856ab72e3d3d8c5bb9c1b59f/assets/fonts/SpaceMono-Regular.ttf
--------------------------------------------------------------------------------
/assets/images/adaptive-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LovesWorking/RN-Dev-Tools-Example/d5cba14f5fd0a92f856ab72e3d3d8c5bb9c1b59f/assets/images/adaptive-icon.png
--------------------------------------------------------------------------------
/assets/images/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LovesWorking/RN-Dev-Tools-Example/d5cba14f5fd0a92f856ab72e3d3d8c5bb9c1b59f/assets/images/favicon.png
--------------------------------------------------------------------------------
/assets/images/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LovesWorking/RN-Dev-Tools-Example/d5cba14f5fd0a92f856ab72e3d3d8c5bb9c1b59f/assets/images/icon.png
--------------------------------------------------------------------------------
/assets/images/partial-react-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LovesWorking/RN-Dev-Tools-Example/d5cba14f5fd0a92f856ab72e3d3d8c5bb9c1b59f/assets/images/partial-react-logo.png
--------------------------------------------------------------------------------
/assets/images/react-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LovesWorking/RN-Dev-Tools-Example/d5cba14f5fd0a92f856ab72e3d3d8c5bb9c1b59f/assets/images/react-logo.png
--------------------------------------------------------------------------------
/assets/images/react-logo@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LovesWorking/RN-Dev-Tools-Example/d5cba14f5fd0a92f856ab72e3d3d8c5bb9c1b59f/assets/images/react-logo@2x.png
--------------------------------------------------------------------------------
/assets/images/react-logo@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LovesWorking/RN-Dev-Tools-Example/d5cba14f5fd0a92f856ab72e3d3d8c5bb9c1b59f/assets/images/react-logo@3x.png
--------------------------------------------------------------------------------
/assets/images/splash-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LovesWorking/RN-Dev-Tools-Example/d5cba14f5fd0a92f856ab72e3d3d8c5bb9c1b59f/assets/images/splash-icon.png
--------------------------------------------------------------------------------
/components/Collapsible.tsx:
--------------------------------------------------------------------------------
1 | import { PropsWithChildren, useState } from 'react';
2 | import { StyleSheet, TouchableOpacity } from 'react-native';
3 |
4 | import { ThemedText } from '@/components/ThemedText';
5 | import { ThemedView } from '@/components/ThemedView';
6 | import { IconSymbol } from '@/components/ui/IconSymbol';
7 | import { Colors } from '@/constants/Colors';
8 | import { useColorScheme } from '@/hooks/useColorScheme';
9 |
10 | export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
11 | const [isOpen, setIsOpen] = useState(false);
12 | const theme = useColorScheme() ?? 'light';
13 |
14 | return (
15 |
16 | setIsOpen((value) => !value)}
19 | activeOpacity={0.8}>
20 |
27 |
28 | {title}
29 |
30 | {isOpen && {children}}
31 |
32 | );
33 | }
34 |
35 | const styles = StyleSheet.create({
36 | heading: {
37 | flexDirection: 'row',
38 | alignItems: 'center',
39 | gap: 6,
40 | },
41 | content: {
42 | marginTop: 6,
43 | marginLeft: 24,
44 | },
45 | });
46 |
--------------------------------------------------------------------------------
/components/EnvDemo.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { StyleSheet, ScrollView } from "react-native";
3 | import { ThemedText } from "@/components/ThemedText";
4 | import { ThemedView } from "@/components/ThemedView";
5 | import { Ionicons } from "@expo/vector-icons";
6 |
7 | interface EnvItemProps {
8 | name: string;
9 | value: string | undefined;
10 | isPublic: boolean;
11 | }
12 |
13 | const EnvItem: React.FC = ({ name, value, isPublic }) => {
14 | return (
15 |
21 |
22 |
27 | {name}
28 |
34 |
35 | {isPublic ? "PUBLIC" : "PRIVATE"}
36 |
37 |
38 |
39 | {value || "Not set"}
40 |
41 | );
42 | };
43 |
44 | export const EnvDemo: React.FC = () => {
45 | // Get all environment variables that start with EXPO_PUBLIC_
46 | const publicEnvVars = Object.entries(process.env)
47 | .filter(([key]) => key.startsWith("EXPO_PUBLIC_"))
48 | .sort(([a], [b]) => a.localeCompare(b));
49 |
50 | // Some private environment variables for demo
51 | const privateEnvVars: [string, string | undefined][] = [
52 | ["NODE_ENV", process.env.NODE_ENV],
53 | ["SECRET_KEY", process.env.SECRET_KEY],
54 | ["DATABASE_URL", process.env.DATABASE_URL],
55 | ["API_SECRET", process.env.API_SECRET],
56 | ];
57 |
58 | return (
59 |
60 | Environment Variables
61 |
62 | Environment variables are automatically synced with DevTools. Public
63 | variables (EXPO_PUBLIC_*) are visible to the client, while private
64 | variables are only available server-side.
65 |
66 |
67 |
71 | {/* Public Environment Variables */}
72 |
73 |
74 | Public Variables ({publicEnvVars.length})
75 |
76 | {publicEnvVars.length > 0 ? (
77 | publicEnvVars.map(([key, value]) => (
78 |
79 | ))
80 | ) : (
81 |
82 |
83 |
84 | No public environment variables found. Add variables starting
85 | with EXPO_PUBLIC_ to see them here.
86 |
87 |
88 | )}
89 |
90 |
91 | {/* Private Environment Variables */}
92 |
93 |
94 | Private Variables (Demo)
95 |
96 | {privateEnvVars.map(([key, value]) => (
97 |
98 | ))}
99 |
100 |
101 |
102 | );
103 | };
104 |
105 | const styles = StyleSheet.create({
106 | container: {
107 | marginTop: 32,
108 | marginBottom: 20,
109 | maxHeight: 400, // Limit height to prevent taking too much space
110 | },
111 | sectionTitle: {
112 | fontSize: 22,
113 | fontWeight: "bold",
114 | marginBottom: 8,
115 | },
116 | description: {
117 | fontSize: 14,
118 | color: "#666",
119 | marginBottom: 20,
120 | lineHeight: 20,
121 | },
122 | scrollContainer: {
123 | flex: 1,
124 | },
125 | section: {
126 | marginBottom: 24,
127 | },
128 | subsectionTitle: {
129 | fontSize: 18,
130 | fontWeight: "600",
131 | marginBottom: 12,
132 | color: "#333",
133 | },
134 | envItem: {
135 | backgroundColor: "rgba(0,0,0,0.03)",
136 | borderRadius: 8,
137 | padding: 12,
138 | marginBottom: 8,
139 | borderLeftWidth: 3,
140 | },
141 | envHeader: {
142 | flexDirection: "row",
143 | alignItems: "center",
144 | marginBottom: 6,
145 | },
146 | envName: {
147 | fontSize: 14,
148 | fontWeight: "600",
149 | marginLeft: 6,
150 | flex: 1,
151 | fontFamily: "monospace",
152 | },
153 | badge: {
154 | paddingHorizontal: 6,
155 | paddingVertical: 2,
156 | borderRadius: 4,
157 | },
158 | badgeText: {
159 | fontSize: 10,
160 | fontWeight: "bold",
161 | color: "#fff",
162 | },
163 | envValue: {
164 | fontSize: 13,
165 | fontFamily: "monospace",
166 | backgroundColor: "rgba(0,0,0,0.05)",
167 | padding: 6,
168 | borderRadius: 4,
169 | color: "#333",
170 | },
171 | emptyState: {
172 | alignItems: "center",
173 | padding: 20,
174 | backgroundColor: "rgba(0,0,0,0.03)",
175 | borderRadius: 8,
176 | },
177 | emptyText: {
178 | fontSize: 14,
179 | color: "#666",
180 | textAlign: "center",
181 | marginTop: 8,
182 | lineHeight: 20,
183 | },
184 | });
185 |
--------------------------------------------------------------------------------
/components/ExternalLink.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from "expo-router";
2 | import { openBrowserAsync } from "expo-web-browser";
3 | import { type ComponentProps } from "react";
4 | import { Platform } from "react-native";
5 |
6 | type Props = Omit, "href"> & { href: string };
7 |
8 | export function ExternalLink({ href, ...rest }: Props) {
9 | return (
10 | {
15 | if (Platform.OS !== "web") {
16 | // Prevent the default behavior of linking to the default browser on native.
17 | event.preventDefault();
18 | // Open the link in an in-app browser.
19 | await openBrowserAsync(href);
20 | }
21 | }}
22 | />
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/components/HapticTab.tsx:
--------------------------------------------------------------------------------
1 | import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
2 | import { PlatformPressable } from '@react-navigation/elements';
3 | import * as Haptics from 'expo-haptics';
4 |
5 | export function HapticTab(props: BottomTabBarButtonProps) {
6 | return (
7 | {
10 | if (process.env.EXPO_OS === 'ios') {
11 | // Add a soft haptic feedback when pressing down on the tabs.
12 | Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
13 | }
14 | props.onPressIn?.(ev);
15 | }}
16 | />
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/components/HelloWave.tsx:
--------------------------------------------------------------------------------
1 | import { StyleSheet } from 'react-native';
2 | import Animated, {
3 | useSharedValue,
4 | useAnimatedStyle,
5 | withTiming,
6 | withRepeat,
7 | withSequence,
8 | } from 'react-native-reanimated';
9 |
10 | import { ThemedText } from '@/components/ThemedText';
11 |
12 | export function HelloWave() {
13 | const rotationAnimation = useSharedValue(0);
14 |
15 | rotationAnimation.value = withRepeat(
16 | withSequence(withTiming(25, { duration: 150 }), withTiming(0, { duration: 150 })),
17 | 4 // Run the animation 4 times
18 | );
19 |
20 | const animatedStyle = useAnimatedStyle(() => ({
21 | transform: [{ rotate: `${rotationAnimation.value}deg` }],
22 | }));
23 |
24 | return (
25 |
26 | 👋
27 |
28 | );
29 | }
30 |
31 | const styles = StyleSheet.create({
32 | text: {
33 | fontSize: 28,
34 | lineHeight: 32,
35 | marginTop: -6,
36 | },
37 | });
38 |
--------------------------------------------------------------------------------
/components/ParallaxScrollView.tsx:
--------------------------------------------------------------------------------
1 | import type { PropsWithChildren, ReactElement } from 'react';
2 | import { StyleSheet } from 'react-native';
3 | import Animated, {
4 | interpolate,
5 | useAnimatedRef,
6 | useAnimatedStyle,
7 | useScrollViewOffset,
8 | } from 'react-native-reanimated';
9 |
10 | import { ThemedView } from '@/components/ThemedView';
11 | import { useBottomTabOverflow } from '@/components/ui/TabBarBackground';
12 | import { useColorScheme } from '@/hooks/useColorScheme';
13 |
14 | const HEADER_HEIGHT = 250;
15 |
16 | type Props = PropsWithChildren<{
17 | headerImage: ReactElement;
18 | headerBackgroundColor: { dark: string; light: string };
19 | }>;
20 |
21 | export default function ParallaxScrollView({
22 | children,
23 | headerImage,
24 | headerBackgroundColor,
25 | }: Props) {
26 | const colorScheme = useColorScheme() ?? 'light';
27 | const scrollRef = useAnimatedRef();
28 | const scrollOffset = useScrollViewOffset(scrollRef);
29 | const bottom = useBottomTabOverflow();
30 | const headerAnimatedStyle = useAnimatedStyle(() => {
31 | return {
32 | transform: [
33 | {
34 | translateY: interpolate(
35 | scrollOffset.value,
36 | [-HEADER_HEIGHT, 0, HEADER_HEIGHT],
37 | [-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
38 | ),
39 | },
40 | {
41 | scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
42 | },
43 | ],
44 | };
45 | });
46 |
47 | return (
48 |
49 |
54 |
60 | {headerImage}
61 |
62 | {children}
63 |
64 |
65 | );
66 | }
67 |
68 | const styles = StyleSheet.create({
69 | container: {
70 | flex: 1,
71 | },
72 | header: {
73 | height: HEADER_HEIGHT,
74 | overflow: 'hidden',
75 | },
76 | content: {
77 | flex: 1,
78 | padding: 32,
79 | gap: 16,
80 | overflow: 'hidden',
81 | },
82 | });
83 |
--------------------------------------------------------------------------------
/components/StorageDemo.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { StyleSheet, TouchableOpacity, TextInput, Alert } from "react-native";
3 | import { ThemedText } from "@/components/ThemedText";
4 | import { ThemedView } from "@/components/ThemedView";
5 | import { Ionicons } from "@expo/vector-icons";
6 | import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
7 | import AsyncStorage from "@react-native-async-storage/async-storage";
8 | import * as SecureStore from "expo-secure-store";
9 | import { storage } from "../storage/mmkv";
10 |
11 | interface StorageItemProps {
12 | storageType: "mmkv" | "async" | "secure";
13 | storageKey: string;
14 | title: string;
15 | icon: keyof typeof Ionicons.glyphMap;
16 | color: string;
17 | }
18 |
19 | const StorageItem: React.FC = ({
20 | storageType,
21 | storageKey,
22 | title,
23 | icon,
24 | color,
25 | }) => {
26 | const queryClient = useQueryClient();
27 |
28 | // Query to read from storage using the special storage query key format
29 | const { data: storedValue, isLoading } = useQuery({
30 | queryKey: ["#storage", storageType, storageKey],
31 | queryFn: async () => {
32 | try {
33 | switch (storageType) {
34 | case "mmkv":
35 | // Use async method for mock MMKV
36 | return await storage.getStringAsync(storageKey);
37 | case "async":
38 | return await AsyncStorage.getItem(storageKey);
39 | case "secure":
40 | return await SecureStore.getItemAsync(storageKey);
41 | default:
42 | return null;
43 | }
44 | } catch (error) {
45 | console.error(`Error reading from ${storageType}:`, error);
46 | return null;
47 | }
48 | },
49 | staleTime: 0, // Always refetch to show real-time updates
50 | });
51 |
52 | return (
53 |
54 |
55 |
56 | {title}
57 |
58 |
59 |
60 | Current Value
61 |
62 | {isLoading ? "Loading..." : storedValue || "No value stored"}
63 |
64 |
65 |
66 | );
67 | };
68 |
69 | const StorageInputCard: React.FC = () => {
70 | const [inputValue, setInputValue] = useState("");
71 | const [selectedStorage, setSelectedStorage] = useState<
72 | "mmkv" | "async" | "secure"
73 | >("mmkv");
74 | const queryClient = useQueryClient();
75 |
76 | const storageOptions = [
77 | {
78 | value: "mmkv",
79 | label: "MMKV Storage",
80 | key: "demo_mmkv_value",
81 | icon: "flash",
82 | color: "#8b5cf6",
83 | },
84 | {
85 | value: "async",
86 | label: "AsyncStorage",
87 | key: "demo_async_value",
88 | icon: "server",
89 | color: "#06b6d4",
90 | },
91 | {
92 | value: "secure",
93 | label: "SecureStore",
94 | key: "userToken",
95 | icon: "shield-checkmark",
96 | color: "#f59e0b",
97 | },
98 | ];
99 |
100 | const currentOption = storageOptions.find(
101 | (opt) => opt.value === selectedStorage
102 | )!;
103 |
104 | // Get current value for selected storage
105 | const { data: currentValue } = useQuery({
106 | queryKey: ["#storage", selectedStorage, currentOption.key],
107 | queryFn: async () => {
108 | try {
109 | switch (selectedStorage) {
110 | case "mmkv":
111 | return await storage.getStringAsync(currentOption.key);
112 | case "async":
113 | return await AsyncStorage.getItem(currentOption.key);
114 | case "secure":
115 | return await SecureStore.getItemAsync(currentOption.key);
116 | default:
117 | return null;
118 | }
119 | } catch (error) {
120 | console.error(`Error reading from ${selectedStorage}:`, error);
121 | return null;
122 | }
123 | },
124 | staleTime: 0,
125 | });
126 |
127 | // Mutation to write to storage
128 | const writeMutation = useMutation({
129 | mutationFn: async (value: string) => {
130 | switch (selectedStorage) {
131 | case "mmkv":
132 | await storage.setAsync(currentOption.key, value);
133 | break;
134 | case "async":
135 | await AsyncStorage.setItem(currentOption.key, value);
136 | break;
137 | case "secure":
138 | await SecureStore.setItemAsync(currentOption.key, value);
139 | break;
140 | }
141 | },
142 | onSuccess: () => {
143 | queryClient.invalidateQueries({
144 | queryKey: ["#storage", selectedStorage, currentOption.key],
145 | });
146 | setInputValue("");
147 | },
148 | onError: (error) => {
149 | Alert.alert(
150 | "Error",
151 | `Failed to save to ${currentOption.label}: ${error.message}`
152 | );
153 | },
154 | });
155 |
156 | // Mutation to delete from storage
157 | const deleteMutation = useMutation({
158 | mutationFn: async () => {
159 | switch (selectedStorage) {
160 | case "mmkv":
161 | await storage.deleteAsync(currentOption.key);
162 | break;
163 | case "async":
164 | await AsyncStorage.removeItem(currentOption.key);
165 | break;
166 | case "secure":
167 | await SecureStore.deleteItemAsync(currentOption.key);
168 | break;
169 | }
170 | },
171 | onSuccess: () => {
172 | queryClient.invalidateQueries({
173 | queryKey: ["#storage", selectedStorage, currentOption.key],
174 | });
175 | Alert.alert("Success", `Value deleted from ${currentOption.label}!`);
176 | },
177 | onError: (error) => {
178 | Alert.alert(
179 | "Error",
180 | `Failed to delete from ${currentOption.label}: ${error.message}`
181 | );
182 | },
183 | });
184 |
185 | const handleWrite = () => {
186 | if (inputValue.trim()) {
187 | writeMutation.mutate(inputValue.trim());
188 | }
189 | };
190 |
191 | const handleDelete = () => {
192 | Alert.alert(
193 | "Confirm Delete",
194 | `Are you sure you want to delete the value from ${currentOption.label}?`,
195 | [
196 | { text: "Cancel", style: "cancel" },
197 | {
198 | text: "Delete",
199 | style: "destructive",
200 | onPress: () => deleteMutation.mutate(),
201 | },
202 | ]
203 | );
204 | };
205 |
206 | return (
207 |
208 |
209 |
210 | Update Storage
211 |
212 |
213 |
214 |
215 | Select Storage Type
216 |
217 |
218 | {storageOptions.map((option) => (
219 | setSelectedStorage(option.value as any)}
227 | >
228 |
233 |
240 | {option.label}
241 |
242 |
243 | ))}
244 |
245 |
246 |
247 |
248 |
255 |
256 |
266 |
272 | Save
273 |
274 |
284 |
290 | Delete
291 |
292 |
293 |
294 |
295 | );
296 | };
297 |
298 | export const StorageDemo: React.FC = () => {
299 | return (
300 |
301 | Storage Demo
302 |
303 | Test storage sync with DevTools. Values will appear in real-time in the
304 | DevTools interface.
305 | {"\n\n"}Note: MMKV is mocked using AsyncStorage for Expo Go
306 | compatibility.
307 |
308 |
309 |
316 |
317 |
324 |
325 |
332 |
333 |
334 |
335 | );
336 | };
337 |
338 | const styles = StyleSheet.create({
339 | container: {
340 | marginTop: 24,
341 | marginBottom: 16,
342 | },
343 | sectionTitle: {
344 | fontSize: 24,
345 | fontWeight: "800",
346 | marginBottom: 6,
347 | textAlign: "center",
348 | },
349 | description: {
350 | fontSize: 14,
351 | color: "#666",
352 | marginBottom: 20,
353 | lineHeight: 20,
354 | textAlign: "center",
355 | paddingHorizontal: 16,
356 | },
357 | storageItem: {
358 | backgroundColor: "#fff",
359 | borderRadius: 12,
360 | padding: 12,
361 | marginBottom: 8,
362 | marginHorizontal: 4,
363 | shadowColor: "#000",
364 | shadowOffset: {
365 | width: 0,
366 | height: 2,
367 | },
368 | shadowOpacity: 0.06,
369 | shadowRadius: 4,
370 | elevation: 2,
371 | borderWidth: 1,
372 | borderColor: "rgba(255,255,255,0.2)",
373 | },
374 | storageHeader: {
375 | flexDirection: "row",
376 | alignItems: "center",
377 | marginBottom: 8,
378 | paddingBottom: 6,
379 | borderBottomWidth: 1,
380 | borderBottomColor: "rgba(0,0,0,0.06)",
381 | },
382 | storageTitle: {
383 | fontSize: 14,
384 | fontWeight: "700",
385 | marginLeft: 8,
386 | color: "#1a1a1a",
387 | },
388 | currentValue: {
389 | backgroundColor: "rgba(0,0,0,0.02)",
390 | borderRadius: 8,
391 | padding: 8,
392 | },
393 | valueLabel: {
394 | fontSize: 10,
395 | color: "#666",
396 | marginBottom: 4,
397 | fontWeight: "600",
398 | textTransform: "uppercase",
399 | letterSpacing: 0.5,
400 | },
401 | valueText: {
402 | fontSize: 12,
403 | fontFamily: "monospace",
404 | backgroundColor: "#f8f9fa",
405 | padding: 8,
406 | borderRadius: 6,
407 | borderWidth: 1,
408 | borderColor: "rgba(0,0,0,0.08)",
409 | color: "#2d3748",
410 | lineHeight: 16,
411 | },
412 | inputContainer: {
413 | gap: 10,
414 | },
415 | input: {
416 | height: 44,
417 | borderRadius: 12,
418 | paddingHorizontal: 16,
419 | fontSize: 14,
420 | backgroundColor: "#f8f9fa",
421 | borderWidth: 2,
422 | borderColor: "rgba(0,0,0,0.08)",
423 | color: "#2d3748",
424 | fontWeight: "500",
425 | },
426 | buttonContainer: {
427 | flexDirection: "row",
428 | gap: 8,
429 | marginTop: 4,
430 | },
431 | actionButton: {
432 | flex: 1,
433 | height: 40,
434 | borderRadius: 12,
435 | justifyContent: "center",
436 | alignItems: "center",
437 | flexDirection: "row",
438 | shadowColor: "#000",
439 | shadowOffset: {
440 | width: 0,
441 | height: 2,
442 | },
443 | shadowOpacity: 0.1,
444 | shadowRadius: 4,
445 | elevation: 2,
446 | },
447 | saveButton: {
448 | backgroundColor: "#10b981",
449 | },
450 | deleteButton: {
451 | backgroundColor: "#ef4444",
452 | },
453 | buttonText: {
454 | color: "#fff",
455 | fontSize: 14,
456 | fontWeight: "700",
457 | marginLeft: 6,
458 | },
459 | buttonIcon: {
460 | marginRight: 2,
461 | },
462 | disabledButton: {
463 | opacity: 0.6,
464 | shadowOpacity: 0.05,
465 | },
466 | inputCard: {
467 | backgroundColor: "#fff",
468 | borderRadius: 16,
469 | padding: 16,
470 | marginTop: 20,
471 | marginBottom: 12,
472 | marginHorizontal: 4,
473 | shadowColor: "#000",
474 | shadowOffset: {
475 | width: 0,
476 | height: 4,
477 | },
478 | shadowOpacity: 0.08,
479 | shadowRadius: 8,
480 | elevation: 4,
481 | borderWidth: 1,
482 | borderColor: "rgba(255,255,255,0.2)",
483 | },
484 | inputCardHeader: {
485 | flexDirection: "row",
486 | alignItems: "center",
487 | marginBottom: 12,
488 | paddingBottom: 8,
489 | borderBottomWidth: 1,
490 | borderBottomColor: "rgba(0,0,0,0.06)",
491 | },
492 | inputCardTitle: {
493 | fontSize: 16,
494 | fontWeight: "700",
495 | marginLeft: 10,
496 | color: "#1a1a1a",
497 | },
498 | dropdownContainer: {
499 | marginBottom: 16,
500 | },
501 | dropdownLabel: {
502 | fontSize: 12,
503 | color: "#666",
504 | marginBottom: 6,
505 | fontWeight: "600",
506 | textTransform: "uppercase",
507 | letterSpacing: 0.5,
508 | },
509 | dropdown: {
510 | backgroundColor: "#f8f9fa",
511 | borderRadius: 12,
512 | padding: 8,
513 | },
514 | dropdownOption: {
515 | flexDirection: "row",
516 | alignItems: "center",
517 | padding: 8,
518 | borderRadius: 8,
519 | },
520 | dropdownOptionSelected: {
521 | backgroundColor: "rgba(79, 70, 229, 0.1)",
522 | },
523 | dropdownOptionText: {
524 | fontSize: 14,
525 | color: "#666",
526 | marginLeft: 8,
527 | },
528 | dropdownOptionTextSelected: {
529 | color: "#4f46e5",
530 | fontWeight: "700",
531 | },
532 | inputSection: {
533 | gap: 10,
534 | },
535 | });
536 |
--------------------------------------------------------------------------------
/components/ThemedText.tsx:
--------------------------------------------------------------------------------
1 | import { Text, type TextProps, StyleSheet } from 'react-native';
2 |
3 | import { useThemeColor } from '@/hooks/useThemeColor';
4 |
5 | export type ThemedTextProps = TextProps & {
6 | lightColor?: string;
7 | darkColor?: string;
8 | type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
9 | };
10 |
11 | export function ThemedText({
12 | style,
13 | lightColor,
14 | darkColor,
15 | type = 'default',
16 | ...rest
17 | }: ThemedTextProps) {
18 | const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
19 |
20 | return (
21 |
33 | );
34 | }
35 |
36 | const styles = StyleSheet.create({
37 | default: {
38 | fontSize: 16,
39 | lineHeight: 24,
40 | },
41 | defaultSemiBold: {
42 | fontSize: 16,
43 | lineHeight: 24,
44 | fontWeight: '600',
45 | },
46 | title: {
47 | fontSize: 32,
48 | fontWeight: 'bold',
49 | lineHeight: 32,
50 | },
51 | subtitle: {
52 | fontSize: 20,
53 | fontWeight: 'bold',
54 | },
55 | link: {
56 | lineHeight: 30,
57 | fontSize: 16,
58 | color: '#0a7ea4',
59 | },
60 | });
61 |
--------------------------------------------------------------------------------
/components/ThemedView.tsx:
--------------------------------------------------------------------------------
1 | import { View, type ViewProps } from 'react-native';
2 |
3 | import { useThemeColor } from '@/hooks/useThemeColor';
4 |
5 | export type ThemedViewProps = ViewProps & {
6 | lightColor?: string;
7 | darkColor?: string;
8 | };
9 |
10 | export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
11 | const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
12 |
13 | return ;
14 | }
15 |
--------------------------------------------------------------------------------
/components/__tests__/ThemedText-test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import renderer from 'react-test-renderer';
3 |
4 | import { ThemedText } from '../ThemedText';
5 |
6 | it(`renders correctly`, () => {
7 | const tree = renderer.create(Snapshot test!).toJSON();
8 |
9 | expect(tree).toMatchSnapshot();
10 | });
11 |
--------------------------------------------------------------------------------
/components/__tests__/__snapshots__/ThemedText-test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`renders correctly 1`] = `
4 |
22 | Snapshot test!
23 |
24 | `;
25 |
--------------------------------------------------------------------------------
/components/ui/IconSymbol.ios.tsx:
--------------------------------------------------------------------------------
1 | import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols';
2 | import { StyleProp, ViewStyle } from 'react-native';
3 |
4 | export function IconSymbol({
5 | name,
6 | size = 24,
7 | color,
8 | style,
9 | weight = 'regular',
10 | }: {
11 | name: SymbolViewProps['name'];
12 | size?: number;
13 | color: string;
14 | style?: StyleProp;
15 | weight?: SymbolWeight;
16 | }) {
17 | return (
18 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/components/ui/IconSymbol.tsx:
--------------------------------------------------------------------------------
1 | // This file is a fallback for using MaterialIcons on Android and web.
2 |
3 | import MaterialIcons from "@expo/vector-icons/MaterialIcons";
4 | import { SymbolWeight } from "expo-symbols";
5 | import React from "react";
6 | import { OpaqueColorValue, StyleProp, TextStyle } from "react-native";
7 |
8 | // Add your SFSymbol to MaterialIcons mappings here.
9 | const MAPPING = {
10 | // See MaterialIcons here: https://icons.expo.fyi
11 | // See SF Symbols in the SF Symbols app on Mac.
12 | "house.fill": "home",
13 | "paperplane.fill": "send",
14 | "chevron.left.forwardslash.chevron.right": "code",
15 | "chevron.right": "chevron-right",
16 | } as Partial<
17 | Record<
18 | import("expo-symbols").SymbolViewProps["name"],
19 | React.ComponentProps["name"]
20 | >
21 | >;
22 |
23 | export type IconSymbolName = keyof typeof MAPPING;
24 |
25 | /**
26 | * An icon component that uses native SFSymbols on iOS, and MaterialIcons on Android and web. This ensures a consistent look across platforms, and optimal resource usage.
27 | *
28 | * Icon `name`s are based on SFSymbols and require manual mapping to MaterialIcons.
29 | */
30 | export function IconSymbol({
31 | name,
32 | size = 24,
33 | color,
34 | style,
35 | }: {
36 | name: IconSymbolName;
37 | size?: number;
38 | color: string | OpaqueColorValue;
39 | style?: StyleProp;
40 | weight?: SymbolWeight;
41 | }) {
42 | return (
43 |
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/components/ui/TabBarBackground.ios.tsx:
--------------------------------------------------------------------------------
1 | import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
2 | import { BlurView } from 'expo-blur';
3 | import { StyleSheet } from 'react-native';
4 | import { useSafeAreaInsets } from 'react-native-safe-area-context';
5 |
6 | export default function BlurTabBarBackground() {
7 | return (
8 |
15 | );
16 | }
17 |
18 | export function useBottomTabOverflow() {
19 | const tabHeight = useBottomTabBarHeight();
20 | const { bottom } = useSafeAreaInsets();
21 | return tabHeight - bottom;
22 | }
23 |
--------------------------------------------------------------------------------
/components/ui/TabBarBackground.tsx:
--------------------------------------------------------------------------------
1 | // This is a shim for web and Android where the tab bar is generally opaque.
2 | export default undefined;
3 |
4 | export function useBottomTabOverflow() {
5 | return 0;
6 | }
7 |
--------------------------------------------------------------------------------
/constants/Colors.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Below are the colors that are used in the app. The colors are defined in the light and dark mode.
3 | * There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
4 | */
5 |
6 | const tintColorLight = '#0a7ea4';
7 | const tintColorDark = '#fff';
8 |
9 | export const Colors = {
10 | light: {
11 | text: '#11181C',
12 | background: '#fff',
13 | tint: tintColorLight,
14 | icon: '#687076',
15 | tabIconDefault: '#687076',
16 | tabIconSelected: tintColorLight,
17 | },
18 | dark: {
19 | text: '#ECEDEE',
20 | background: '#151718',
21 | tint: tintColorDark,
22 | icon: '#9BA1A6',
23 | tabIconDefault: '#9BA1A6',
24 | tabIconSelected: tintColorDark,
25 | },
26 | };
27 |
--------------------------------------------------------------------------------
/hooks/useColorScheme.ts:
--------------------------------------------------------------------------------
1 | export { useColorScheme } from 'react-native';
2 |
--------------------------------------------------------------------------------
/hooks/useColorScheme.web.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { useColorScheme as useRNColorScheme } from 'react-native';
3 |
4 | /**
5 | * To support static rendering, this value needs to be re-calculated on the client side for web
6 | */
7 | export function useColorScheme() {
8 | const [hasHydrated, setHasHydrated] = useState(false);
9 |
10 | useEffect(() => {
11 | setHasHydrated(true);
12 | }, []);
13 |
14 | const colorScheme = useRNColorScheme();
15 |
16 | if (hasHydrated) {
17 | return colorScheme;
18 | }
19 |
20 | return 'light';
21 | }
22 |
--------------------------------------------------------------------------------
/hooks/useThemeColor.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Learn more about light and dark modes:
3 | * https://docs.expo.dev/guides/color-schemes/
4 | */
5 |
6 | import { Colors } from '@/constants/Colors';
7 | import { useColorScheme } from '@/hooks/useColorScheme';
8 |
9 | export function useThemeColor(
10 | props: { light?: string; dark?: string },
11 | colorName: keyof typeof Colors.light & keyof typeof Colors.dark
12 | ) {
13 | const theme = useColorScheme() ?? 'light';
14 | const colorFromProps = props[theme];
15 |
16 | if (colorFromProps) {
17 | return colorFromProps;
18 | } else {
19 | return Colors[theme][colorName];
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "rn-dev-tools-exmaple",
3 | "main": "expo-router/entry",
4 | "version": "1.0.0",
5 | "scripts": {
6 | "start": "expo start",
7 | "reset-project": "node ./scripts/reset-project.js",
8 | "android": "expo run:android",
9 | "ios": "expo run:ios",
10 | "web": "expo start --web",
11 | "test": "jest --watchAll",
12 | "rd": "rm -rf node_modules/react-native-react-query-devtools && npm uninstall react-native-react-query-devtools && npm install ./react-native-react-query-devtools-1.3.8.tgz",
13 | "lint": "expo lint"
14 | },
15 | "jest": {
16 | "preset": "jest-expo"
17 | },
18 | "dependencies": {
19 | "@expo/vector-icons": "^14.1.0",
20 | "@react-native-async-storage/async-storage": "^2.1.2",
21 | "@react-navigation/bottom-tabs": "^7.0.0",
22 | "@react-navigation/native": "^7.0.0",
23 | "@tanstack/react-query": "^5.62.0",
24 | "expo": "^53.0.0",
25 | "expo-blur": "~14.1.4",
26 | "expo-clipboard": "~7.1.4",
27 | "expo-constants": "~17.1.6",
28 | "expo-font": "~13.3.1",
29 | "expo-haptics": "~14.1.4",
30 | "expo-linking": "~7.1.5",
31 | "expo-router": "~5.0.7",
32 | "expo-secure-store": "^14.2.3",
33 | "expo-splash-screen": "~0.30.8",
34 | "expo-status-bar": "~2.2.3",
35 | "expo-symbols": "~0.4.4",
36 | "expo-system-ui": "~5.0.7",
37 | "expo-web-browser": "~14.1.6",
38 | "i": "^0.3.7",
39 | "npm": "^11.2.0",
40 | "react": "19.0.0",
41 | "react-dom": "19.0.0",
42 | "react-native": "0.79.2",
43 | "react-native-gesture-handler": "~2.24.0",
44 | "react-native-reanimated": "~3.17.4",
45 | "react-native-safe-area-context": "5.4.0",
46 | "react-native-screens": "~4.10.0",
47 | "react-native-svg": "^15.11.2",
48 | "react-native-web": "^0.20.0",
49 | "react-native-webview": "13.13.5",
50 | "tanstack-query-dev-tools-expo-plugin": "^0.1.1"
51 | },
52 | "devDependencies": {
53 | "@babel/core": "^7.25.2",
54 | "@types/jest": "^29.5.12",
55 | "@types/react": "~19.0.10",
56 | "jest": "^29.2.1",
57 | "jest-expo": "~53.0.5",
58 | "react-query-external-sync": "^2.1.0",
59 | "socket.io-client": "^4.8.1",
60 | "typescript": "~5.8.3"
61 | },
62 | "private": true
63 | }
64 |
--------------------------------------------------------------------------------
/scripts/reset-project.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | /**
4 | * This script is used to reset the project to a blank state.
5 | * It moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example and creates a new /app directory with an index.tsx and _layout.tsx file.
6 | * You can remove the `reset-project` script from package.json and safely delete this file after running it.
7 | */
8 |
9 | const fs = require("fs");
10 | const path = require("path");
11 |
12 | const root = process.cwd();
13 | const oldDirs = ["app", "components", "hooks", "constants", "scripts"];
14 | const newDir = "app-example";
15 | const newAppDir = "app";
16 | const newDirPath = path.join(root, newDir);
17 |
18 | const indexContent = `import { Text, View } from "react-native";
19 |
20 | export default function Index() {
21 | return (
22 |
29 | Edit app/index.tsx to edit this screen.
30 |
31 | );
32 | }
33 | `;
34 |
35 | const layoutContent = `import { Stack } from "expo-router";
36 |
37 | export default function RootLayout() {
38 | return ;
39 | }
40 | `;
41 |
42 | const moveDirectories = async () => {
43 | try {
44 | // Create the app-example directory
45 | await fs.promises.mkdir(newDirPath, { recursive: true });
46 | console.log(`📁 /${newDir} directory created.`);
47 |
48 | // Move old directories to new app-example directory
49 | for (const dir of oldDirs) {
50 | const oldDirPath = path.join(root, dir);
51 | const newDirPath = path.join(root, newDir, dir);
52 | if (fs.existsSync(oldDirPath)) {
53 | await fs.promises.rename(oldDirPath, newDirPath);
54 | console.log(`➡️ /${dir} moved to /${newDir}/${dir}.`);
55 | } else {
56 | console.log(`➡️ /${dir} does not exist, skipping.`);
57 | }
58 | }
59 |
60 | // Create new /app directory
61 | const newAppDirPath = path.join(root, newAppDir);
62 | await fs.promises.mkdir(newAppDirPath, { recursive: true });
63 | console.log("\n📁 New /app directory created.");
64 |
65 | // Create index.tsx
66 | const indexPath = path.join(newAppDirPath, "index.tsx");
67 | await fs.promises.writeFile(indexPath, indexContent);
68 | console.log("📄 app/index.tsx created.");
69 |
70 | // Create _layout.tsx
71 | const layoutPath = path.join(newAppDirPath, "_layout.tsx");
72 | await fs.promises.writeFile(layoutPath, layoutContent);
73 | console.log("📄 app/_layout.tsx created.");
74 |
75 | console.log("\n✅ Project reset complete. Next steps:");
76 | console.log(
77 | "1. Run `npx expo start` to start a development server.\n2. Edit app/index.tsx to edit the main screen.\n3. Delete the /app-example directory when you're done referencing it."
78 | );
79 | } catch (error) {
80 | console.error(`Error during script execution: ${error}`);
81 | }
82 | };
83 |
84 | moveDirectories();
85 |
--------------------------------------------------------------------------------
/storage/mmkv.ts:
--------------------------------------------------------------------------------
1 | import AsyncStorage from "@react-native-async-storage/async-storage";
2 |
3 | // Mock MMKV implementation for Expo Go compatibility
4 | // In a real app with development builds, you would use the actual MMKV package
5 |
6 | class MockMMKV {
7 | private id: string;
8 | private encryptionKey?: string;
9 |
10 | constructor(config: { id: string; encryptionKey?: string }) {
11 | this.id = config.id;
12 | this.encryptionKey = config.encryptionKey;
13 | }
14 |
15 | private getKey(key: string): string {
16 | return `mmkv_${this.id}_${key}`;
17 | }
18 |
19 | set(key: string, value: string | number | boolean): void {
20 | const storageKey = this.getKey(key);
21 | const stringValue =
22 | typeof value === "string" ? value : JSON.stringify(value);
23 | AsyncStorage.setItem(storageKey, stringValue).catch(console.error);
24 | }
25 |
26 | async setAsync(key: string, value: string | number | boolean): Promise {
27 | const storageKey = this.getKey(key);
28 | const stringValue =
29 | typeof value === "string" ? value : JSON.stringify(value);
30 | await AsyncStorage.setItem(storageKey, stringValue);
31 | }
32 |
33 | getString(key: string): string | undefined {
34 | // Note: This is synchronous in real MMKV, but async in our mock
35 | // For demo purposes, we'll return undefined and handle async in the components
36 | return undefined;
37 | }
38 |
39 | async getStringAsync(key: string): Promise {
40 | const storageKey = this.getKey(key);
41 | return await AsyncStorage.getItem(storageKey);
42 | }
43 |
44 | getNumber(key: string): number | undefined {
45 | return undefined;
46 | }
47 |
48 | async getNumberAsync(key: string): Promise {
49 | const storageKey = this.getKey(key);
50 | const value = await AsyncStorage.getItem(storageKey);
51 | return value ? parseFloat(value) : null;
52 | }
53 |
54 | getBoolean(key: string): boolean | undefined {
55 | return undefined;
56 | }
57 |
58 | async getBooleanAsync(key: string): Promise {
59 | const storageKey = this.getKey(key);
60 | const value = await AsyncStorage.getItem(storageKey);
61 | return value ? JSON.parse(value) : null;
62 | }
63 |
64 | delete(key: string): void {
65 | const storageKey = this.getKey(key);
66 | AsyncStorage.removeItem(storageKey).catch(console.error);
67 | }
68 |
69 | async deleteAsync(key: string): Promise {
70 | const storageKey = this.getKey(key);
71 | await AsyncStorage.removeItem(storageKey);
72 | }
73 |
74 | getAllKeys(): string[] {
75 | // In real MMKV this is synchronous, but we'll need to handle this async
76 | return [];
77 | }
78 |
79 | async getAllKeysAsync(): Promise {
80 | const allKeys = await AsyncStorage.getAllKeys();
81 | const prefix = `mmkv_${this.id}_`;
82 | return allKeys
83 | .filter((key) => key.startsWith(prefix))
84 | .map((key) => key.replace(prefix, ""));
85 | }
86 |
87 | clearAll(): void {
88 | this.getAllKeysAsync()
89 | .then((keys) => {
90 | keys.forEach((key) => this.delete(key));
91 | })
92 | .catch(console.error);
93 | }
94 | }
95 |
96 | // Create mock MMKV storage instance
97 | export const storage = new MockMMKV({
98 | id: "rn-dev-tools-example",
99 | encryptionKey: "demo-encryption-key", // In production, use a secure key
100 | });
101 |
102 | // Helper functions for easier usage with async operations
103 | export const mmkvStorage = {
104 | setItem: async (key: string, value: string): Promise => {
105 | await storage.setAsync(key, value);
106 | },
107 | getItem: async (key: string): Promise => {
108 | return await storage.getStringAsync(key);
109 | },
110 | removeItem: async (key: string): Promise => {
111 | await storage.deleteAsync(key);
112 | },
113 | clear: () => {
114 | storage.clearAll();
115 | },
116 | getAllKeys: async (): Promise => {
117 | return await storage.getAllKeysAsync();
118 | },
119 | };
120 |
121 | export default storage;
122 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "expo/tsconfig.base",
3 | "compilerOptions": {
4 | "strict": true,
5 | "paths": {
6 | "@/*": [
7 | "./*"
8 | ]
9 | }
10 | },
11 | "include": [
12 | "**/*.ts",
13 | "**/*.tsx",
14 | ".expo/types/**/*.ts",
15 | "expo-env.d.ts"
16 | ]
17 | }
18 |
--------------------------------------------------------------------------------