├── .gitignore ├── src ├── react-query-external-sync │ ├── index.ts │ ├── User.ts │ ├── hooks │ │ ├── index.ts │ │ ├── storageQueryKeys.ts │ │ ├── useDynamicEnvQueries.ts │ │ ├── useDynamicMmkvQueries.ts │ │ ├── useStorageQueries.ts │ │ ├── useDynamicAsyncStorageQueries.ts │ │ └── useDynamicSecureStorageQueries.ts │ ├── types.ts │ ├── hydration.ts │ ├── platformUtils.ts │ ├── useMySocket.ts │ ├── utils │ │ ├── storageHandlers.ts │ │ └── logger.ts │ └── useSyncQueriesExternal.ts └── index.ts ├── rollup.config.mjs ├── tsconfig.json ├── LICENSE ├── scripts ├── README.md ├── github-release.js └── auto-release.js ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ -------------------------------------------------------------------------------- /src/react-query-external-sync/index.ts: -------------------------------------------------------------------------------- 1 | // Export the main hook 2 | export { useMySocket as useQuerySyncSocket } from "./useMySocket"; 3 | -------------------------------------------------------------------------------- /src/react-query-external-sync/User.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | id: string; 3 | deviceName: string; 4 | deviceId?: string; // Optional for backward compatibility 5 | platform?: string; // Device platform (iOS, Android, Web) 6 | extraDeviceInfo?: string; // json string of additional device information as key-value pairs 7 | envVariables?: Record; // Environment variables from the mobile app 8 | } 9 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import terser from "@rollup/plugin-terser"; 2 | 3 | export default { 4 | input: "dist/index.js", 5 | output: [ 6 | { 7 | file: "dist/bundle.cjs.js", 8 | format: "cjs", 9 | }, 10 | { 11 | file: "dist/bundle.esm.js", 12 | format: "esm", 13 | }, 14 | ], 15 | external: ["react", "socket.io-client", "@tanstack/react-query"], 16 | plugins: [terser()], 17 | }; 18 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export let useSyncQueriesExternal: typeof import("./react-query-external-sync/useSyncQueriesExternal").useSyncQueriesExternal; 2 | // @ts-ignore process.env.NODE_ENV is defined by metro transform plugins 3 | if (process.env.NODE_ENV !== "production") { 4 | useSyncQueriesExternal = 5 | require("./react-query-external-sync/useSyncQueriesExternal").useSyncQueriesExternal; 6 | } else { 7 | // In production, this becomes a no-op function 8 | useSyncQueriesExternal = () => ({ 9 | isConnected: false, 10 | connect: () => {}, 11 | disconnect: () => {}, 12 | socket: null, 13 | users: [], 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /src/react-query-external-sync/hooks/index.ts: -------------------------------------------------------------------------------- 1 | // Storage Query Keys 2 | export { storageQueryKeys } from './storageQueryKeys'; 3 | 4 | // Individual Storage Hooks 5 | export { 6 | type AsyncStorageQueryResult, 7 | useDynamicAsyncStorageQueries, 8 | type UseDynamicAsyncStorageQueriesOptions, 9 | } from './useDynamicAsyncStorageQueries'; 10 | export { 11 | type MmkvQueryResult, 12 | type MmkvStorage, 13 | useDynamicMmkvQueries, 14 | type UseDynamicMmkvQueriesOptions, 15 | } from './useDynamicMmkvQueries'; 16 | export { 17 | type SecureStorageQueryResult, 18 | useDynamicSecureStorageQueries, 19 | type UseDynamicSecureStorageQueriesOptions, 20 | } from './useDynamicSecureStorageQueries'; 21 | 22 | // Unified Storage Hook 23 | export { type StorageQueryResults, useStorageQueries, type UseStorageQueriesOptions } from './useStorageQueries'; 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ESNext", // Ideal for Rollup's ES module support 4 | "target": "ES6", // Compile to ES6 5 | "lib": ["dom", "dom.iterable", "esnext"], // Specify libraries 6 | "jsx": "react-jsx", // For React projects 7 | "moduleResolution": "node", // Use Node.js module resolution 8 | "esModuleInterop": true, // Enables ESModule interop 9 | "skipLibCheck": true, // Skip type checking of declaration files 10 | "forceConsistentCasingInFileNames": true, // Force file name casing to be consistent 11 | "outDir": "./dist", // Output directory for compiled files 12 | "declaration": true, // Generate corresponding '.d.ts' file 13 | "declarationDir": "./dist/types", // Directory for declaration files 14 | "sourceMap": true // Generate source maps for debugging 15 | }, 16 | "include": ["src/**/*"], // Include all files in src 17 | "exclude": ["node_modules", "dist"] // Exclude node_modules and dist from compilation 18 | } 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 austin johnson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/react-query-external-sync/hooks/storageQueryKeys.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Centralized storage query keys for all storage hooks 3 | * This ensures consistency across MMKV, AsyncStorage, and SecureStorage hooks 4 | * and allows easy modification of the base storage key in one place 5 | */ 6 | export const storageQueryKeys = { 7 | /** 8 | * Base storage key - change this to update all storage-related queries 9 | */ 10 | base: () => ['#storage'] as const, 11 | 12 | /** 13 | * MMKV storage query keys 14 | */ 15 | mmkv: { 16 | root: () => [...storageQueryKeys.base(), 'mmkv'] as const, 17 | key: (key: string) => [...storageQueryKeys.mmkv.root(), key] as const, 18 | all: () => [...storageQueryKeys.mmkv.root(), 'all'] as const, 19 | }, 20 | 21 | /** 22 | * AsyncStorage query keys 23 | */ 24 | async: { 25 | root: () => [...storageQueryKeys.base(), 'async'] as const, 26 | key: (key: string) => [...storageQueryKeys.async.root(), key] as const, 27 | all: () => [...storageQueryKeys.async.root(), 'all'] as const, 28 | }, 29 | 30 | /** 31 | * SecureStorage query keys 32 | */ 33 | secure: { 34 | root: () => [...storageQueryKeys.base(), 'secure'] as const, 35 | key: (key: string) => [...storageQueryKeys.secure.root(), key] as const, 36 | all: () => [...storageQueryKeys.secure.root(), 'all'] as const, 37 | }, 38 | } as const; 39 | -------------------------------------------------------------------------------- /scripts/README.md: -------------------------------------------------------------------------------- 1 | # Release Automation Scripts 2 | 3 | This directory contains scripts to automate the release process for the react-query-external-sync package. 4 | 5 | ## Scripts 6 | 7 | ### `auto-release.js` 8 | 9 | The main automated release script that handles the entire release workflow: 10 | 11 | - ✅ Checks for uncommitted changes and offers to commit them 12 | - ✅ Interactive version selection (patch/minor/major) 13 | - ✅ Builds the package 14 | - ✅ Bumps version and creates git tag 15 | - ✅ Pushes changes and tags to git 16 | - ✅ Publishes to npm 17 | - ✅ Creates GitHub release with auto-generated release notes 18 | 19 | **Usage:** 20 | 21 | ```bash 22 | npm run release:auto 23 | ``` 24 | 25 | ### `github-release.js` 26 | 27 | Creates a GitHub release with auto-generated release notes based on commits since the last tag. 28 | 29 | **Usage:** 30 | 31 | ```bash 32 | npm run github:release 33 | ``` 34 | 35 | ## Setup for GitHub Releases 36 | 37 | To enable automatic GitHub release creation, you need to set up a GitHub token: 38 | 39 | 1. Go to https://github.com/settings/tokens 40 | 2. Create a new token with "repo" permissions 41 | 3. Add it to your environment: 42 | ```bash 43 | export GITHUB_TOKEN=your_token_here 44 | ``` 45 | 4. Or add it to your `~/.zshrc` or `~/.bashrc` for persistence 46 | 47 | ## Available npm Scripts 48 | 49 | - `npm run release:auto` - Interactive automated release 50 | - `npm run release:patch` - Direct patch release 51 | - `npm run release:minor` - Direct minor release 52 | - `npm run release:major` - Direct major release 53 | - `npm run github:release` - Create GitHub release only 54 | - `npm run pre-release` - Check git status before release 55 | -------------------------------------------------------------------------------- /src/react-query-external-sync/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DefaultError, 3 | MutationKey, 4 | MutationMeta, 5 | MutationScope, 6 | MutationState, 7 | QueryKey, 8 | QueryMeta, 9 | QueryObserverOptions, 10 | QueryState, 11 | } from '@tanstack/react-query'; 12 | // Define a simplified version of DehydratedState that both versions can work with 13 | export interface SimpleDehydratedState { 14 | mutations: unknown[]; 15 | queries: unknown[]; 16 | } 17 | 18 | export interface SyncMessage { 19 | type: 'dehydrated-state'; 20 | state: DehydratedState; 21 | isOnlineManagerOnline: boolean; 22 | persistentDeviceId: string; 23 | } 24 | 25 | export interface DehydratedState { 26 | mutations: DehydratedMutation[]; 27 | queries: DehydratedQuery[]; 28 | } 29 | 30 | export interface DehydratedMutation { 31 | mutationId: number; 32 | mutationKey?: MutationKey; 33 | state: MutationState; 34 | meta?: MutationMeta; 35 | scope?: MutationScope; 36 | gcTime?: number; 37 | } 38 | export interface DehydratedQuery { 39 | queryHash: string; 40 | queryKey: QueryKey; 41 | state: QueryState; 42 | promise?: Promise; 43 | meta?: QueryMeta; 44 | observers: ObserverState[]; 45 | gcTime?: number; 46 | } 47 | export interface ObserverState< 48 | TQueryFnData = unknown, 49 | TError = DefaultError, 50 | TData = TQueryFnData, 51 | TQueryData = TQueryFnData, 52 | TQueryKey extends QueryKey = QueryKey, 53 | > { 54 | queryHash: string; 55 | options: QueryObserverOptions; 56 | } 57 | 58 | export interface User { 59 | id: string; 60 | deviceName: string; 61 | deviceId: string; // Persisted device ID 62 | platform?: string; // Device platform (iOS, Android, Web) 63 | isConnected?: boolean; // Whether the device is currently connected 64 | extraDeviceInfo?: string; // json string of additional device information as key-value pairs 65 | envVariables?: Record; // Environment variables from the mobile app 66 | } 67 | -------------------------------------------------------------------------------- /src/react-query-external-sync/hydration.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | DefaultError, 3 | Mutation, 4 | MutationOptions, 5 | Query, 6 | QueryClient, 7 | QueryFunction, 8 | QueryOptions, 9 | } from '@tanstack/react-query'; 10 | 11 | import { DehydratedMutation, DehydratedQuery, DehydratedState, ObserverState } from './types'; 12 | type TransformerFn = (data: unknown) => unknown; 13 | 14 | export function Dehydrate(client: QueryClient): DehydratedState { 15 | const mutations = client 16 | .getMutationCache() 17 | .getAll() 18 | .flatMap((mutation) => [dehydrateMutation(mutation)]); 19 | 20 | const queries = client 21 | .getQueryCache() 22 | .getAll() 23 | .flatMap((query) => [dehydrateQuery(query)]); 24 | return { mutations, queries }; 25 | } 26 | export interface DehydrateOptions { 27 | serializeData?: TransformerFn; 28 | shouldDehydrateMutation?: (mutation: Mutation) => boolean; 29 | shouldDehydrateQuery?: (query: Query) => boolean; 30 | shouldRedactErrors?: (error: unknown) => boolean; 31 | } 32 | 33 | export interface HydrateOptions { 34 | defaultOptions?: { 35 | deserializeData?: TransformerFn; 36 | queries?: QueryOptions; 37 | mutations?: MutationOptions; 38 | }; 39 | } 40 | 41 | function dehydrateMutation(mutation: Mutation): DehydratedMutation { 42 | return { 43 | mutationId: mutation.mutationId, 44 | mutationKey: mutation.options.mutationKey, 45 | state: mutation.state, 46 | gcTime: mutation.gcTime, 47 | ...(mutation.options.scope && { scope: mutation.options.scope }), 48 | ...(mutation.meta && { meta: mutation.meta }), 49 | }; 50 | } 51 | 52 | function dehydrateQuery(query: Query): DehydratedQuery { 53 | // Extract observer states 54 | const observerStates: ObserverState[] = query.observers.map((observer) => ({ 55 | queryHash: query.queryHash, 56 | options: observer.options, 57 | // Remove queryFn from observer options to prevent not being able to capture fetch action 58 | queryFn: undefined as unknown as QueryFunction, 59 | })); 60 | 61 | return { 62 | state: { 63 | ...query.state, 64 | ...(query.state.data !== undefined && { 65 | data: query.state.data, 66 | }), 67 | }, 68 | queryKey: query.queryKey, 69 | queryHash: query.queryHash, 70 | gcTime: query.gcTime, 71 | ...(query.meta && { meta: query.meta }), 72 | observers: observerStates, 73 | }; 74 | } 75 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-query-external-sync", 3 | "version": "2.2.3", 4 | "description": "A tool for syncing React Query state to an external Dev Tools", 5 | "main": "dist/bundle.cjs.js", 6 | "module": "dist/bundle.esm.js", 7 | "files": [ 8 | "dist" 9 | ], 10 | "types": "dist/types/index.d.ts", 11 | "scripts": { 12 | "build": "tsc --outDir dist --declarationDir dist/types --declaration true && rollup -c rollup.config.mjs", 13 | "test": "echo \"Error: no test specified\" && exit 1", 14 | "version:patch": "npm version patch", 15 | "version:minor": "npm version minor", 16 | "version:major": "npm version major", 17 | "prepublishOnly": "npm run build", 18 | "release:patch": "npm run version:patch && git push && git push --tags && npm publish && npm run github:release", 19 | "release:minor": "npm run version:minor && git push && git push --tags && npm publish && npm run github:release", 20 | "release:major": "npm run version:major && git push && git push --tags && npm publish && npm run github:release", 21 | "release:auto": "node scripts/auto-release.js", 22 | "github:release": "node scripts/github-release.js", 23 | "pre-release": "git add . && git status" 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "https://github.com/LovesWorking/react-query-external-sync" 28 | }, 29 | "keywords": [ 30 | "expo", 31 | "devtools", 32 | "tanstack", 33 | "query", 34 | "react-query", 35 | "react-native", 36 | "expo-react-native", 37 | "expo-react-native-tanstack-query-devtools", 38 | "tanstack-query", 39 | "tanstack-query-devtools", 40 | "tanstack-query-devtools-expo", 41 | "tanstack-query-devtools-expo-react-native", 42 | "tanstack-query-devtools-expo-plugin", 43 | "tanstack-query-devtools-expo-plugin-react-native", 44 | "tanstack-query-devtools-expo-plugin-webui", 45 | "tanstack-query-devtools-expo-plugin-webui-react-native", 46 | "React-Query-Dev-Tools" 47 | ], 48 | "author": "LovesWorking (https://github.com/LovesWorking)", 49 | "license": "MIT", 50 | "dependencies": {}, 51 | "peerDependencies": { 52 | "@tanstack/react-query": "^4.0.0 || ^5.0.0", 53 | "react": "^18 || ^19", 54 | "socket.io-client": "*" 55 | }, 56 | "peerDependenciesMeta": { 57 | "react-native": { 58 | "optional": true 59 | }, 60 | "@react-native-async-storage/async-storage": { 61 | "optional": true 62 | }, 63 | "socket.io-client": { 64 | "optional": true 65 | } 66 | }, 67 | "devDependencies": { 68 | "@babel/core": "^7.23.9", 69 | "@babel/preset-env": "^7.23.9", 70 | "@babel/preset-react": "^7.23.3", 71 | "@babel/preset-typescript": "^7.23.3", 72 | "@rollup/plugin-json": "^6.1.0", 73 | "@rollup/plugin-node-resolve": "^15.2.3", 74 | "@rollup/plugin-terser": "^0.4.4", 75 | "@rollup/plugin-typescript": "^11.1.6", 76 | "@tanstack/react-query": "^5.66.9", 77 | "@types/node": "^20.11.16", 78 | "@types/react": "^18.2.55", 79 | "rollup": "^4.9.6", 80 | "tslib": "^2.6.2", 81 | "typescript": "^5.3.3" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/react-query-external-sync/hooks/useDynamicEnvQueries.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | 3 | export interface UseDynamicEnvOptions { 4 | /** 5 | * Optional filter function to determine which env vars to include 6 | * Note: Only EXPO_PUBLIC_ prefixed variables are available in process.env 7 | */ 8 | envFilter?: (key: string, value: string | undefined) => boolean; 9 | } 10 | 11 | export interface EnvResult { 12 | key: string; 13 | data: unknown; 14 | } 15 | 16 | /** 17 | * Hook that returns all available environment variables with parsed values 18 | * Includes all available environment variables by default (only EXPO_PUBLIC_ prefixed vars are loaded by Expo) 19 | * 20 | * @example 21 | * // Get all available environment variables (only EXPO_PUBLIC_ prefixed) 22 | * const envVars = useDynamicEnv(); 23 | * // Returns: [ 24 | * // { key: 'EXPO_PUBLIC_API_URL', data: 'https://api.example.com' }, 25 | * // { key: 'EXPO_PUBLIC_APP_NAME', data: 'MyApp' }, 26 | * // ... 27 | * // ] 28 | * 29 | * @example 30 | * // Filter to specific variables 31 | * const envVars = useDynamicEnv({ 32 | * envFilter: (key) => key.includes('API') || key.includes('URL') 33 | * }); 34 | * 35 | * @example 36 | * // Filter by value content 37 | * const envVars = useDynamicEnv({ 38 | * envFilter: (key, value) => value !== undefined && value.length > 0 39 | * }); 40 | */ 41 | export function useDynamicEnv({ 42 | envFilter = () => true, // Default: include all available environment variables (EXPO_PUBLIC_ only) 43 | }: UseDynamicEnvOptions = {}): EnvResult[] { 44 | // Helper function to get a single environment variable value 45 | const getEnvValue = useMemo(() => { 46 | return (key: string): unknown => { 47 | const value = process.env[key]; 48 | 49 | if (value === undefined) { 50 | return null; 51 | } 52 | 53 | // Try to parse as JSON for complex values, fall back to string 54 | try { 55 | // Only attempt JSON parsing if it looks like JSON (starts with { or [) 56 | if (value.startsWith('{') || value.startsWith('[')) { 57 | return JSON.parse(value); 58 | } 59 | 60 | // Parse boolean-like strings 61 | if (value.toLowerCase() === 'true') return true; 62 | if (value.toLowerCase() === 'false') return false; 63 | 64 | // Parse number-like strings 65 | if (/^\d+$/.test(value)) { 66 | const num = parseInt(value, 10); 67 | return !isNaN(num) ? num : value; 68 | } 69 | 70 | if (/^\d*\.\d+$/.test(value)) { 71 | const num = parseFloat(value); 72 | return !isNaN(num) ? num : value; 73 | } 74 | 75 | return value; 76 | } catch { 77 | return value; 78 | } 79 | }; 80 | }, []); 81 | 82 | // Get all environment variables and process them 83 | const envResults = useMemo(() => { 84 | const allEnvKeys = Object.keys(process.env); 85 | const filteredKeys = allEnvKeys.filter((key) => { 86 | const value = process.env[key]; 87 | return envFilter(key, value); 88 | }); 89 | 90 | return filteredKeys.map((key) => ({ 91 | key, 92 | data: getEnvValue(key), 93 | })); 94 | }, [envFilter, getEnvValue]); 95 | 96 | return envResults; 97 | } 98 | -------------------------------------------------------------------------------- /src/react-query-external-sync/platformUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Platform and storage utilities that work across different environments (React Native, web, etc.) 3 | */ 4 | 5 | // Types 6 | /** 7 | * Valid platform operating systems that can be used with React Native 8 | * @see https://reactnative.dev/docs/platform 9 | */ 10 | export type PlatformOS = 11 | | "ios" // iOS devices (iPhone, iPad, iPod) 12 | | "android" // Android devices 13 | | "web" // Web browsers 14 | | "windows" // Windows desktop/UWP 15 | | "macos" // macOS desktop 16 | | "native" // Generic native platform 17 | | "tv" // Generic TV platform (Android TV, etc) 18 | | "tvos" // Apple TV 19 | | "visionos" // Apple Vision Pro / visionOS 20 | | "maccatalyst"; // iOS apps running on macOS via Mac Catalyst 21 | 22 | // Storage interface similar to AsyncStorage 23 | export interface StorageInterface { 24 | getItem: (key: string) => Promise; 25 | setItem: (key: string, value: string) => Promise; 26 | removeItem: (key: string) => Promise; 27 | } 28 | 29 | let customStorageImplementation: StorageInterface | null = null; 30 | 31 | // Try to detect if we're in a React Native environment 32 | export const isReactNative = (): boolean => { 33 | try { 34 | return ( 35 | typeof navigator !== "undefined" && navigator.product === "ReactNative" 36 | ); 37 | } catch (e) { 38 | return false; 39 | } 40 | }; 41 | 42 | /** 43 | * Get platform-specific URL for socket connection 44 | * On Android emulator, we need to replace localhost with 10.0.2.2 45 | */ 46 | export const getPlatformSpecificURL = ( 47 | baseUrl: string, 48 | platform: PlatformOS, 49 | isDevice: boolean 50 | ): string => { 51 | try { 52 | const url = new URL(baseUrl); 53 | 54 | // For Android emulator, replace hostname with 10.0.2.2 55 | if ( 56 | !isDevice && 57 | platform === "android" && 58 | (url.hostname === "localhost" || url.hostname === "127.0.0.1") 59 | ) { 60 | url.hostname = "10.0.2.2"; 61 | return url.toString(); 62 | } 63 | 64 | // For other platforms, use as provided 65 | return baseUrl; 66 | } catch (e) { 67 | console.warn("Error getting platform-specific URL:", e); 68 | return baseUrl; 69 | } 70 | }; 71 | 72 | // Storage implementation 73 | export const getStorage = (): StorageInterface => { 74 | // Return user-defined storage if available 75 | if (customStorageImplementation) { 76 | return customStorageImplementation; 77 | } 78 | 79 | // Try to use React Native AsyncStorage if available 80 | if (isReactNative()) { 81 | try { 82 | // Dynamic import to avoid bundling issues 83 | const AsyncStorage = 84 | // eslint-disable-next-line @typescript-eslint/no-var-requires 85 | require("@react-native-async-storage/async-storage").default; 86 | return AsyncStorage; 87 | } catch (e) { 88 | console.warn("Failed to import AsyncStorage from react-native:", e); 89 | } 90 | } 91 | 92 | // Fallback to browser localStorage with an async wrapper 93 | if (typeof localStorage !== "undefined") { 94 | return { 95 | getItem: async (key: string): Promise => { 96 | return localStorage.getItem(key); 97 | }, 98 | setItem: async (key: string, value: string): Promise => { 99 | localStorage.setItem(key, value); 100 | }, 101 | removeItem: async (key: string): Promise => { 102 | localStorage.removeItem(key); 103 | }, 104 | }; 105 | } 106 | 107 | // Memory fallback if nothing else is available 108 | console.warn("No persistent storage available, using in-memory storage"); 109 | const memoryStorage: Record = {}; 110 | return { 111 | getItem: async (key: string): Promise => { 112 | return memoryStorage[key] || null; 113 | }, 114 | setItem: async (key: string, value: string): Promise => { 115 | memoryStorage[key] = value; 116 | }, 117 | removeItem: async (key: string): Promise => { 118 | delete memoryStorage[key]; 119 | }, 120 | }; 121 | }; 122 | 123 | /** 124 | * Set a custom storage implementation 125 | * Use this if you need to provide your own storage solution 126 | */ 127 | export const setCustomStorage = (storage: StorageInterface | null): void => { 128 | customStorageImplementation = storage; 129 | }; 130 | -------------------------------------------------------------------------------- /src/react-query-external-sync/hooks/useDynamicMmkvQueries.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo } from 'react'; 2 | import { QueryClient, useQueries } from '@tanstack/react-query'; 3 | 4 | import { storageQueryKeys } from './storageQueryKeys'; 5 | 6 | // Define the MMKV storage interface for better type safety 7 | export interface MmkvStorage { 8 | getAllKeys(): string[]; 9 | getString(key: string): string | undefined; 10 | getNumber(key: string): number | undefined; 11 | getBoolean(key: string): boolean | undefined; 12 | addOnValueChangedListener(listener: (key: string) => void): { remove: () => void }; 13 | } 14 | 15 | export interface UseDynamicMmkvQueriesOptions { 16 | /** 17 | * The React Query client instance 18 | */ 19 | queryClient: QueryClient; 20 | /** 21 | * The MMKV storage instance to use 22 | */ 23 | storage: MmkvStorage; 24 | } 25 | 26 | export interface MmkvQueryResult { 27 | key: string; 28 | data: unknown; 29 | isLoading: boolean; 30 | error: Error | null; 31 | } 32 | 33 | /** 34 | * Hook that creates individual React Query queries for each MMKV key 35 | * This gives you granular control and better performance since each key has its own query 36 | * Automatically listens for MMKV changes and updates the relevant queries 37 | * 38 | * @example 39 | * // Get individual queries for all MMKV keys 40 | * const queries = useDynamicMmkvQueries({ queryClient, storage }); 41 | * // Returns: [ 42 | * // { key: 'sync_download_progress', data: 75, isLoading: false, error: null }, 43 | * // { key: 'user_preference', data: 'dark', isLoading: false, error: null }, 44 | * // ... 45 | * // ] 46 | */ 47 | export function useDynamicMmkvQueries({ queryClient, storage }: UseDynamicMmkvQueriesOptions): MmkvQueryResult[] { 48 | // Get all MMKV keys 49 | const mmkvKeys = useMemo(() => { 50 | return storage.getAllKeys(); 51 | }, [storage]); 52 | 53 | // Helper function to get a single MMKV value 54 | const getMmkvValue = useMemo(() => { 55 | return (key: string): unknown => { 56 | // Try to get the value as different types since MMKV doesn't tell us the type 57 | const stringValue = storage.getString(key); 58 | if (stringValue !== undefined) { 59 | // Try to parse as JSON, fall back to string 60 | try { 61 | return JSON.parse(stringValue); 62 | } catch { 63 | return stringValue; 64 | } 65 | } 66 | 67 | const numberValue = storage.getNumber(key); 68 | if (numberValue !== undefined) { 69 | return numberValue; 70 | } 71 | 72 | const boolValue = storage.getBoolean(key); 73 | if (boolValue !== undefined) { 74 | return boolValue; 75 | } 76 | 77 | return null; 78 | }; 79 | }, [storage]); 80 | 81 | // Create individual queries for each key 82 | const queries = useQueries( 83 | { 84 | queries: mmkvKeys.map((key) => ({ 85 | queryKey: storageQueryKeys.mmkv.key(key), 86 | queryFn: () => { 87 | // Removed repetitive fetch logs for cleaner output 88 | const value = getMmkvValue(key); 89 | return value; 90 | }, 91 | staleTime: 0, // Always fetch fresh data 92 | gcTime: 5 * 60 * 1000, // 5 minutes 93 | networkMode: 'always' as const, 94 | })), 95 | combine: (results) => { 96 | return results.map((result, index) => ({ 97 | key: mmkvKeys[index], 98 | data: result.data, 99 | isLoading: result.isLoading, 100 | error: result.error, 101 | })); 102 | }, 103 | }, 104 | queryClient, 105 | ); 106 | 107 | // Set up MMKV listener for automatic updates 108 | useEffect(() => { 109 | if (mmkvKeys.length === 0) return; 110 | 111 | // Removed repetitive listener setup logs for cleaner output 112 | 113 | const listener = storage.addOnValueChangedListener((changedKey) => { 114 | // Only invalidate if the changed key is in our list 115 | if (mmkvKeys.includes(changedKey)) { 116 | // Removed repetitive value change logs for cleaner output 117 | queryClient.invalidateQueries({ 118 | queryKey: storageQueryKeys.mmkv.key(changedKey), 119 | }); 120 | } 121 | }); 122 | 123 | return () => { 124 | // Removed repetitive listener cleanup logs for cleaner output 125 | listener.remove(); 126 | }; 127 | }, [mmkvKeys, queryClient, storage]); 128 | 129 | return queries; 130 | } 131 | -------------------------------------------------------------------------------- /src/react-query-external-sync/hooks/useStorageQueries.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient } from '@tanstack/react-query'; 2 | 3 | import { useDynamicAsyncStorageQueries } from './useDynamicAsyncStorageQueries'; 4 | import { MmkvStorage, useDynamicMmkvQueries } from './useDynamicMmkvQueries'; 5 | import { type SecureStoreStatic, useDynamicSecureStorageQueries } from './useDynamicSecureStorageQueries'; 6 | 7 | export interface UseStorageQueriesOptions { 8 | /** 9 | * The React Query client instance 10 | */ 11 | queryClient: QueryClient; 12 | 13 | /** 14 | * MMKV storage instance to monitor all MMKV keys 15 | * Pass your MMKV storage instance or undefined to disable 16 | */ 17 | mmkv?: MmkvStorage; 18 | 19 | /** 20 | * Enable AsyncStorage monitoring 21 | * Set to true to monitor all AsyncStorage keys 22 | */ 23 | asyncStorage?: boolean; 24 | 25 | /** 26 | * SecureStorage configuration 27 | * Pass an array of known keys (SecureStore doesn't expose getAllKeys for security) 28 | * OR pass an object with storage instance and keys 29 | */ 30 | secureStorage?: 31 | | string[] 32 | | { 33 | storage: SecureStoreStatic; 34 | keys: string[]; 35 | }; 36 | 37 | /** 38 | * Optional polling interval for SecureStorage in milliseconds 39 | * Defaults to 1000ms (1 second) 40 | */ 41 | secureStoragePollInterval?: number; 42 | } 43 | 44 | export interface StorageQueryResults { 45 | mmkv: ReturnType; 46 | asyncStorage: ReturnType; 47 | secureStorage: ReturnType; 48 | } 49 | 50 | /** 51 | * Unified hook for monitoring all device storage types with React Query 52 | * 53 | * This hook consolidates MMKV, AsyncStorage, and SecureStorage monitoring into one simple interface. 54 | * Each storage type can be enabled/disabled independently based on your needs. 55 | * 56 | * @example 57 | * // Monitor all storage types (legacy string array format) 58 | * const storageQueries = useStorageQueries({ 59 | * queryClient, 60 | * mmkv: storage, 61 | * asyncStorage: true, 62 | * secureStorage: ['sessionToken', 'auth.session', 'auth.email'] 63 | * }); 64 | * 65 | * @example 66 | * // Monitor with custom SecureStore instance 67 | * import * as SecureStore from 'expo-secure-store'; 68 | * 69 | * const storageQueries = useStorageQueries({ 70 | * queryClient, 71 | * mmkv: storage, 72 | * asyncStorage: true, 73 | * secureStorage: { 74 | * storage: SecureStore, 75 | * keys: ['sessionToken', 'auth.session', 'auth.email'] 76 | * } 77 | * }); 78 | * 79 | * @example 80 | * // Monitor only MMKV storage 81 | * const storageQueries = useStorageQueries({ 82 | * queryClient, 83 | * mmkv: storage 84 | * }); 85 | * 86 | * @example 87 | * // Monitor only specific secure storage keys 88 | * const storageQueries = useStorageQueries({ 89 | * queryClient, 90 | * secureStorage: ['sessionToken', 'refreshToken'], 91 | * secureStoragePollInterval: 2000 // Check every 2 seconds 92 | * }); 93 | */ 94 | export function useStorageQueries({ 95 | queryClient, 96 | mmkv, 97 | asyncStorage, 98 | secureStorage, 99 | secureStoragePollInterval, 100 | }: UseStorageQueriesOptions): StorageQueryResults { 101 | // Always call hooks but with conditional parameters 102 | // MMKV queries - pass a dummy storage if not enabled 103 | const mmkvQueries = useDynamicMmkvQueries({ 104 | queryClient, 105 | storage: mmkv || { 106 | getAllKeys: () => [], 107 | getString: () => undefined, 108 | getNumber: () => undefined, 109 | getBoolean: () => undefined, 110 | addOnValueChangedListener: () => ({ remove: () => {} }), 111 | }, 112 | }); 113 | 114 | // AsyncStorage queries - always call but filter results 115 | const asyncStorageQueries = useDynamicAsyncStorageQueries({ 116 | queryClient, 117 | }); 118 | 119 | // SecureStorage queries - handle both legacy array format and new object format 120 | const secureStorageConfig = Array.isArray(secureStorage) 121 | ? { storage: undefined, keys: secureStorage } 122 | : secureStorage || { storage: undefined, keys: [] }; 123 | 124 | const secureStorageQueries = useDynamicSecureStorageQueries({ 125 | queryClient, 126 | secureStorage: secureStorageConfig.storage, 127 | knownKeys: secureStorageConfig.keys, 128 | pollInterval: secureStoragePollInterval, 129 | }); 130 | 131 | return { 132 | mmkv: mmkv ? mmkvQueries : [], 133 | asyncStorage: asyncStorage ? asyncStorageQueries : [], 134 | secureStorage: secureStorageConfig.keys.length ? secureStorageQueries : [], 135 | }; 136 | } 137 | -------------------------------------------------------------------------------- /scripts/github-release.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const { execSync } = require("child_process"); 4 | const https = require("https"); 5 | const fs = require("fs"); 6 | 7 | function execCommand(command) { 8 | try { 9 | return execSync(command, { encoding: "utf8" }).trim(); 10 | } catch (error) { 11 | console.error(`Command failed: ${command}`); 12 | throw error; 13 | } 14 | } 15 | 16 | function makeGitHubRequest(options, data) { 17 | return new Promise((resolve, reject) => { 18 | const req = https.request(options, (res) => { 19 | let body = ""; 20 | res.on("data", (chunk) => (body += chunk)); 21 | res.on("end", () => { 22 | if (res.statusCode >= 200 && res.statusCode < 300) { 23 | resolve(JSON.parse(body)); 24 | } else { 25 | reject(new Error(`GitHub API error: ${res.statusCode} - ${body}`)); 26 | } 27 | }); 28 | }); 29 | 30 | req.on("error", reject); 31 | 32 | if (data) { 33 | req.write(JSON.stringify(data)); 34 | } 35 | 36 | req.end(); 37 | }); 38 | } 39 | 40 | async function createGitHubRelease() { 41 | try { 42 | // Get package info 43 | const packageJson = JSON.parse(fs.readFileSync("package.json", "utf8")); 44 | const version = packageJson.version; 45 | const tagName = `v${version}`; 46 | 47 | // Get repository info from package.json 48 | const repoUrl = packageJson.repository.url; 49 | const repoMatch = repoUrl.match(/github\.com[\/:]([^\/]+)\/([^\/\.]+)/); 50 | 51 | if (!repoMatch) { 52 | throw new Error("Could not parse GitHub repository from package.json"); 53 | } 54 | 55 | const owner = repoMatch[1]; 56 | const repo = repoMatch[2]; 57 | 58 | // Get GitHub token from environment 59 | const token = process.env.GITHUB_TOKEN; 60 | if (!token) { 61 | console.log("⚠️ GITHUB_TOKEN not found in environment variables."); 62 | console.log("📝 To create GitHub releases automatically, please:"); 63 | console.log(" 1. Go to https://github.com/settings/tokens"); 64 | console.log(' 2. Create a new token with "repo" permissions'); 65 | console.log( 66 | " 3. Add it to your environment: export GITHUB_TOKEN=your_token" 67 | ); 68 | console.log(" 4. Or add it to your ~/.zshrc or ~/.bashrc"); 69 | console.log("\n✅ For now, you can manually create a release at:"); 70 | console.log( 71 | ` https://github.com/${owner}/${repo}/releases/new?tag=${tagName}` 72 | ); 73 | return; 74 | } 75 | 76 | // Get recent commits for release notes 77 | let releaseNotes = ""; 78 | try { 79 | const lastTag = execCommand( 80 | 'git describe --tags --abbrev=0 HEAD~1 2>/dev/null || echo ""' 81 | ); 82 | const commitRange = lastTag ? `${lastTag}..HEAD` : "HEAD"; 83 | const commits = execCommand( 84 | `git log ${commitRange} --pretty=format:"- %s" --no-merges` 85 | ); 86 | releaseNotes = commits || "- Initial release"; 87 | } catch (error) { 88 | releaseNotes = "- Package updates and improvements"; 89 | } 90 | 91 | // Create the release 92 | const releaseData = { 93 | tag_name: tagName, 94 | target_commitish: "main", 95 | name: `Release ${tagName}`, 96 | body: `## Changes\n\n${releaseNotes}\n\n## Installation\n\n\`\`\`bash\nnpm install ${packageJson.name}@${version}\n\`\`\``, 97 | draft: false, 98 | prerelease: version.includes("-"), 99 | }; 100 | 101 | const options = { 102 | hostname: "api.github.com", 103 | port: 443, 104 | path: `/repos/${owner}/${repo}/releases`, 105 | method: "POST", 106 | headers: { 107 | Authorization: `token ${token}`, 108 | "User-Agent": "npm-release-script", 109 | "Content-Type": "application/json", 110 | Accept: "application/vnd.github.v3+json", 111 | }, 112 | }; 113 | 114 | console.log(`🔄 Creating GitHub release for ${tagName}...`); 115 | const release = await makeGitHubRequest(options, releaseData); 116 | 117 | console.log(`✅ GitHub release created successfully!`); 118 | console.log(`🔗 Release URL: ${release.html_url}`); 119 | } catch (error) { 120 | console.error("❌ Failed to create GitHub release:", error.message); 121 | 122 | // Provide fallback instructions 123 | const packageJson = JSON.parse(fs.readFileSync("package.json", "utf8")); 124 | const version = packageJson.version; 125 | const tagName = `v${version}`; 126 | const repoUrl = packageJson.repository.url; 127 | const repoMatch = repoUrl.match(/github\.com[\/:]([^\/]+)\/([^\/\.]+)/); 128 | 129 | if (repoMatch) { 130 | const owner = repoMatch[1]; 131 | const repo = repoMatch[2]; 132 | console.log(`\n📝 You can manually create the release at:`); 133 | console.log( 134 | ` https://github.com/${owner}/${repo}/releases/new?tag=${tagName}` 135 | ); 136 | } 137 | } 138 | } 139 | 140 | createGitHubRelease(); 141 | -------------------------------------------------------------------------------- /scripts/auto-release.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const { execSync } = require("child_process"); 4 | const readline = require("readline"); 5 | 6 | const rl = readline.createInterface({ 7 | input: process.stdin, 8 | output: process.stdout, 9 | }); 10 | 11 | function execCommand(command, description) { 12 | console.log(`\n🔄 ${description}...`); 13 | try { 14 | const output = execSync(command, { 15 | encoding: "utf8", 16 | stdio: "inherit", 17 | env: { ...process.env }, // Ensure environment variables are passed 18 | }); 19 | console.log(`✅ ${description} completed`); 20 | return output; 21 | } catch (error) { 22 | console.error(`❌ ${description} failed:`, error.message); 23 | process.exit(1); 24 | } 25 | } 26 | 27 | function askQuestion(question) { 28 | return new Promise((resolve) => { 29 | rl.question(question, (answer) => { 30 | resolve(answer.trim().toLowerCase()); 31 | }); 32 | }); 33 | } 34 | 35 | async function main() { 36 | console.log("🚀 React Query External Sync - Automated Release\n"); 37 | 38 | // Check GitHub token availability 39 | if (!process.env.GITHUB_TOKEN) { 40 | console.log("⚠️ GITHUB_TOKEN not found in current environment."); 41 | console.log( 42 | "📝 GitHub releases will be skipped, but you can create them manually." 43 | ); 44 | console.log( 45 | "💡 To fix this for next time, restart your terminal after setting the token.\n" 46 | ); 47 | } else { 48 | console.log( 49 | "✅ GitHub token found - releases will be created automatically\n" 50 | ); 51 | } 52 | 53 | // Check if there are uncommitted changes 54 | try { 55 | execSync("git diff --exit-code", { stdio: "ignore" }); 56 | execSync("git diff --cached --exit-code", { stdio: "ignore" }); 57 | } catch (error) { 58 | console.log( 59 | "📝 You have uncommitted changes. Let me show you what needs to be committed:\n" 60 | ); 61 | execCommand("git status", "Checking git status"); 62 | 63 | const shouldCommit = await askQuestion( 64 | "\n❓ Do you want to commit these changes? (y/n): " 65 | ); 66 | if (shouldCommit === "y" || shouldCommit === "yes") { 67 | const commitMessage = await askQuestion( 68 | '💬 Enter commit message (or press Enter for "chore: update package"): ' 69 | ); 70 | const message = commitMessage || "chore: update package"; 71 | execCommand("git add .", "Staging changes"); 72 | execCommand(`git commit -m "${message}"`, "Committing changes"); 73 | } else { 74 | console.log("❌ Please commit your changes before releasing"); 75 | process.exit(1); 76 | } 77 | } 78 | 79 | console.log("\n📦 What type of release is this?"); 80 | console.log("1. patch (2.2.1 → 2.2.2) - Bug fixes"); 81 | console.log("2. minor (2.2.1 → 2.3.0) - New features"); 82 | console.log("3. major (2.2.1 → 3.0.0) - Breaking changes"); 83 | 84 | const versionType = await askQuestion( 85 | "\n❓ Enter your choice (1/2/3 or patch/minor/major): " 86 | ); 87 | 88 | let releaseType; 89 | switch (versionType) { 90 | case "1": 91 | case "patch": 92 | releaseType = "patch"; 93 | break; 94 | case "2": 95 | case "minor": 96 | releaseType = "minor"; 97 | break; 98 | case "3": 99 | case "major": 100 | releaseType = "major"; 101 | break; 102 | default: 103 | console.log("❌ Invalid choice. Defaulting to patch release."); 104 | releaseType = "patch"; 105 | } 106 | 107 | console.log(`\n🎯 Proceeding with ${releaseType} release...\n`); 108 | 109 | // Confirm before proceeding 110 | const confirm = await askQuestion( 111 | `❓ Are you sure you want to release a ${releaseType} version? (y/n): ` 112 | ); 113 | if (confirm !== "y" && confirm !== "yes") { 114 | console.log("❌ Release cancelled"); 115 | process.exit(0); 116 | } 117 | 118 | rl.close(); 119 | 120 | // Execute the release 121 | console.log("\n🚀 Starting automated release process...\n"); 122 | 123 | try { 124 | // Build the package 125 | execCommand("npm run build", "Building package"); 126 | 127 | // Version bump (this also creates a git tag) 128 | execCommand(`npm version ${releaseType}`, `Bumping ${releaseType} version`); 129 | 130 | // Push changes and tags 131 | execCommand("git push", "Pushing changes to git"); 132 | execCommand("git push --tags", "Pushing tags to git"); 133 | 134 | // Publish to npm 135 | execCommand("npm publish", "Publishing to npm"); 136 | 137 | // Create GitHub release (with better error handling) 138 | if (process.env.GITHUB_TOKEN) { 139 | execCommand("npm run github:release", "Creating GitHub release"); 140 | } else { 141 | console.log("\n⚠️ Skipping GitHub release (no token available)"); 142 | console.log("💡 You can create it manually or restart terminal and run:"); 143 | console.log(" npm run github:release"); 144 | } 145 | 146 | console.log("\n🎉 Release completed successfully!"); 147 | console.log("✅ Version bumped and committed"); 148 | console.log("✅ Changes pushed to git"); 149 | console.log("✅ Package published to npm"); 150 | 151 | if (process.env.GITHUB_TOKEN) { 152 | console.log("✅ GitHub release created"); 153 | } else { 154 | console.log("⚠️ GitHub release skipped (restart terminal to fix)"); 155 | } 156 | } catch (error) { 157 | console.error("\n❌ Release failed:", error.message); 158 | process.exit(1); 159 | } 160 | } 161 | 162 | main().catch(console.error); 163 | -------------------------------------------------------------------------------- /src/react-query-external-sync/useMySocket.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | import { io as socketIO, Socket } from 'socket.io-client'; 3 | 4 | import { log } from './utils/logger'; 5 | import { getPlatformSpecificURL, PlatformOS } from './platformUtils'; 6 | 7 | interface Props { 8 | deviceName: string; // Unique name to identify the device 9 | socketURL: string; // Base URL of the socket server (may be modified based on platform) 10 | persistentDeviceId: string | null; // Persistent device ID 11 | extraDeviceInfo?: Record; // Additional device information as key-value pairs 12 | envVariables?: Record; // Environment variables from the mobile app 13 | platform: PlatformOS; // Platform identifier 14 | /** 15 | * Enable/disable logging for debugging purposes 16 | * @default false 17 | */ 18 | enableLogs?: boolean; 19 | /** 20 | * Whether the app is running on a physical device or an emulator/simulator 21 | * This can affect how the socket URL is constructed, especially on Android 22 | * @default false 23 | */ 24 | isDevice?: boolean // Whether the app is running on a physical device 25 | } 26 | 27 | /** 28 | * Create a singleton socket instance that persists across component renders 29 | * This way multiple components can share the same socket connection 30 | */ 31 | let globalSocketInstance: Socket | null = null; 32 | let currentSocketURL = ''; 33 | 34 | /** 35 | * Hook that handles socket connection for device-dashboard communication 36 | * 37 | * Features: 38 | * - Singleton pattern for socket connection 39 | * - Platform-specific URL handling for iOS/Android/Web 40 | * - Device name identification 41 | * - Connection state tracking 42 | * - User list management 43 | */ 44 | export function useMySocket({ 45 | deviceName, 46 | socketURL, 47 | persistentDeviceId, 48 | extraDeviceInfo, 49 | envVariables, 50 | platform, 51 | enableLogs = false, 52 | isDevice = false 53 | }: Props) { 54 | const socketRef = useRef(null); 55 | const [socket, setSocket] = useState(null); 56 | const [isConnected, setIsConnected] = useState(false); 57 | const initialized = useRef(false); 58 | 59 | // For logging clarity 60 | const logPrefix = `[${deviceName}]`; 61 | 62 | // Main socket initialization - runs only once 63 | useEffect(() => { 64 | // Wait until we have a persistent device ID 65 | if (!persistentDeviceId) { 66 | return; 67 | } 68 | 69 | // Only initialize socket once to prevent multiple connections 70 | if (initialized.current) { 71 | return; 72 | } 73 | 74 | initialized.current = true; 75 | 76 | // Define event handlers inside useEffect to avoid dependency issues 77 | const onConnect = () => { 78 | log(`${logPrefix} Socket connected successfully`, enableLogs); 79 | setIsConnected(true); 80 | }; 81 | 82 | const onDisconnect = (reason: string) => { 83 | log(`${logPrefix} Socket disconnected. Reason: ${reason}`, enableLogs); 84 | setIsConnected(false); 85 | }; 86 | 87 | const onConnectError = (error: Error) => { 88 | log(`${logPrefix} Socket connection error: ${error.message}`, enableLogs, 'error'); 89 | }; 90 | 91 | const onConnectTimeout = () => { 92 | log(`${logPrefix} Socket connection timeout`, enableLogs, 'error'); 93 | }; 94 | 95 | // Get the platform-specific URL 96 | const platformUrl = getPlatformSpecificURL(socketURL, platform, isDevice); 97 | currentSocketURL = platformUrl; 98 | 99 | try { 100 | // Use existing global socket or create a new one 101 | if (!globalSocketInstance) { 102 | globalSocketInstance = socketIO(platformUrl, { 103 | autoConnect: true, 104 | query: { 105 | deviceName, 106 | deviceId: persistentDeviceId, 107 | platform, 108 | extraDeviceInfo: JSON.stringify(extraDeviceInfo), 109 | envVariables: JSON.stringify(envVariables), 110 | }, 111 | reconnection: false, 112 | transports: ['websocket'], // Prefer websocket transport for React Native 113 | }); 114 | } else { 115 | log(`${logPrefix} Reusing existing socket instance to ${platformUrl}`, enableLogs); 116 | } 117 | 118 | socketRef.current = globalSocketInstance; 119 | setSocket(socketRef.current); 120 | 121 | // Setup error event listener 122 | socketRef.current.on('connect_error', onConnectError); 123 | socketRef.current.on('connect_timeout', onConnectTimeout); 124 | 125 | // Check initial connection state 126 | if (socketRef.current.connected) { 127 | setIsConnected(true); 128 | log(`${logPrefix} Socket already connected on init`, enableLogs); 129 | } 130 | 131 | // Set up event handlers 132 | socketRef.current.on('connect', onConnect); 133 | socketRef.current.on('disconnect', onDisconnect); 134 | 135 | // Clean up event listeners on unmount but don't disconnect 136 | return () => { 137 | if (socketRef.current) { 138 | log(`${logPrefix} Cleaning up socket event listeners`, enableLogs); 139 | socketRef.current.off('connect', onConnect); 140 | socketRef.current.off('disconnect', onDisconnect); 141 | socketRef.current.off('connect_error', onConnectError); 142 | socketRef.current.off('connect_timeout', onConnectTimeout); 143 | // Don't disconnect socket on component unmount 144 | // We want it to remain connected for the app's lifetime 145 | } 146 | }; 147 | } catch (error) { 148 | log(`${logPrefix} Failed to initialize socket: ${error}`, enableLogs, 'error'); 149 | } 150 | // ## DON'T ADD ANYTHING ELSE TO THE DEPENDENCY ARRAY ### 151 | // eslint-disable-next-line react-hooks/exhaustive-deps 152 | }, [persistentDeviceId]); 153 | 154 | // Update the socket query parameters when deviceName changes 155 | useEffect(() => { 156 | if (socketRef.current && socketRef.current.io.opts.query && persistentDeviceId) { 157 | socketRef.current.io.opts.query = { 158 | ...socketRef.current.io.opts.query, 159 | deviceName, 160 | deviceId: persistentDeviceId, 161 | platform, 162 | }; 163 | } 164 | }, [deviceName, logPrefix, persistentDeviceId, platform, enableLogs]); 165 | 166 | // Update the socket URL when socketURL changes 167 | useEffect(() => { 168 | // Get platform-specific URL for the new socketURL 169 | const platformUrl = getPlatformSpecificURL(socketURL, platform, isDevice); 170 | 171 | // Compare with last known URL to avoid direct property access 172 | if (socketRef.current && currentSocketURL !== platformUrl && persistentDeviceId) { 173 | log(`${logPrefix} Socket URL changed from ${currentSocketURL} to ${platformUrl}`, enableLogs); 174 | 175 | try { 176 | // Only recreate socket if URL actually changed 177 | socketRef.current.disconnect(); 178 | currentSocketURL = platformUrl; 179 | 180 | log(`${logPrefix} Creating new socket connection to ${platformUrl}`, enableLogs); 181 | globalSocketInstance = socketIO(platformUrl, { 182 | autoConnect: true, 183 | query: { 184 | deviceName, 185 | deviceId: persistentDeviceId, 186 | platform, 187 | extraDeviceInfo: JSON.stringify(extraDeviceInfo), 188 | envVariables: JSON.stringify(envVariables), 189 | }, 190 | reconnection: false, 191 | transports: ['websocket'], // Prefer websocket transport for React Native 192 | }); 193 | 194 | socketRef.current = globalSocketInstance; 195 | setSocket(socketRef.current); 196 | } catch (error) { 197 | log(`${logPrefix} Failed to update socket connection: ${error}`, enableLogs, 'error'); 198 | } 199 | } 200 | }, [socketURL, deviceName, logPrefix, persistentDeviceId, platform, enableLogs, extraDeviceInfo, envVariables]); 201 | 202 | /** 203 | * Manually connect to the socket server 204 | */ 205 | function connect() { 206 | if (socketRef.current && !socketRef.current.connected) { 207 | log(`${logPrefix} Manually connecting to socket server`, enableLogs); 208 | socketRef.current.connect(); 209 | } 210 | } 211 | 212 | /** 213 | * Manually disconnect from the socket server 214 | */ 215 | function disconnect() { 216 | if (socketRef.current && socketRef.current.connected) { 217 | log(`${logPrefix} Manually disconnecting from socket server`, enableLogs); 218 | socketRef.current.disconnect(); 219 | } 220 | } 221 | 222 | return { 223 | socket, 224 | connect, 225 | disconnect, 226 | isConnected, 227 | }; 228 | } 229 | -------------------------------------------------------------------------------- /src/react-query-external-sync/hooks/useDynamicAsyncStorageQueries.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useState } from 'react'; 2 | import { QueryClient, useQueries } from '@tanstack/react-query'; 3 | 4 | import { storageQueryKeys } from './storageQueryKeys'; 5 | 6 | /** 7 | * AsyncStorage static interface (from @react-native-async-storage/async-storage) 8 | */ 9 | export interface AsyncStorageStatic { 10 | getItem: (key: string) => Promise; 11 | getAllKeys: () => Promise; 12 | setItem: (key: string, value: string) => Promise; 13 | removeItem: (key: string) => Promise; 14 | } 15 | 16 | export interface UseDynamicAsyncStorageQueriesOptions { 17 | /** 18 | * The React Query client instance 19 | */ 20 | queryClient: QueryClient; 21 | /** 22 | * AsyncStorage instance to use for storage operations 23 | * Pass your AsyncStorage instance from @react-native-async-storage/async-storage 24 | * If not provided, the hook will be disabled 25 | */ 26 | asyncStorage?: AsyncStorageStatic; 27 | /** 28 | * Optional interval in milliseconds to poll for key changes 29 | * Defaults to 5000ms (5 seconds). Set to 0 to disable polling. 30 | */ 31 | pollInterval?: number; 32 | /** 33 | * Whether to enable AsyncStorage monitoring 34 | * When false, no queries will be created and no polling will occur 35 | * @default true 36 | */ 37 | enabled?: boolean; 38 | } 39 | 40 | export interface AsyncStorageQueryResult { 41 | key: string; 42 | data: unknown; 43 | isLoading: boolean; 44 | error: Error | null; 45 | } 46 | 47 | /** 48 | * Hook that creates individual React Query queries for each AsyncStorage key 49 | * This gives you granular control and better performance since each key has its own query 50 | * Since AsyncStorage doesn't have built-in change listeners, this hook uses polling to detect changes 51 | * 52 | * @example 53 | * // Get individual queries for all AsyncStorage keys 54 | * const queries = useDynamicAsyncStorageQueries({ queryClient }); 55 | * // Returns: [ 56 | * // { key: '@notifications:status', data: 'enabled', isLoading: false, error: null }, 57 | * // { key: '@user:preferences', data: { theme: 'dark' }, isLoading: false, error: null }, 58 | * // ... 59 | * // ] 60 | */ 61 | export function useDynamicAsyncStorageQueries({ 62 | queryClient, 63 | asyncStorage, 64 | pollInterval = 1000, 65 | enabled = true, 66 | }: UseDynamicAsyncStorageQueriesOptions): AsyncStorageQueryResult[] { 67 | // State to track AsyncStorage keys (since getAllKeys is async) 68 | const [asyncStorageKeys, setAsyncStorageKeys] = useState([]); 69 | 70 | // Helper function to get a single AsyncStorage value 71 | const getAsyncStorageValue = useMemo(() => { 72 | return async (key: string): Promise => { 73 | if (!asyncStorage) { 74 | return null; 75 | } 76 | 77 | try { 78 | const value = await asyncStorage.getItem(key); 79 | if (value === null) { 80 | return null; 81 | } 82 | 83 | // Try to parse as JSON, fall back to string 84 | try { 85 | return JSON.parse(value); 86 | } catch { 87 | return value; 88 | } 89 | } catch (error) { 90 | console.error('Error getting AsyncStorage value for key:', key, error); 91 | throw error; 92 | } 93 | }; 94 | }, [asyncStorage]); 95 | 96 | // Function to refresh the list of AsyncStorage keys 97 | const refreshKeys = useMemo(() => { 98 | return async () => { 99 | if (!enabled || !asyncStorage) { 100 | setAsyncStorageKeys([]); 101 | return; 102 | } 103 | 104 | try { 105 | const keys = await asyncStorage.getAllKeys(); 106 | // Filter out React Query cache and other noisy keys 107 | const filteredKeys = keys.filter( 108 | (key) => !key.includes('REACT_QUERY_OFFLINE_CACHE') && !key.includes('RCTAsyncLocalStorage'), 109 | ); 110 | setAsyncStorageKeys([...filteredKeys]); // Convert readonly array to mutable array 111 | } catch (error) { 112 | console.error('📱 [AsyncStorage Hook] Error getting AsyncStorage keys:', error); 113 | setAsyncStorageKeys([]); 114 | } 115 | }; 116 | }, [enabled, asyncStorage]); 117 | 118 | // Initial load of keys 119 | useEffect(() => { 120 | refreshKeys(); 121 | }, [refreshKeys]); 122 | 123 | // Set up polling for key changes (since AsyncStorage doesn't have listeners) 124 | useEffect(() => { 125 | if (!enabled || pollInterval <= 0 || !asyncStorage) { 126 | return; 127 | } 128 | 129 | const interval = setInterval(async () => { 130 | try { 131 | const currentKeys = await asyncStorage.getAllKeys(); 132 | // Filter out React Query cache and other noisy keys 133 | const filteredKeys = currentKeys.filter( 134 | (key) => !key.includes('REACT_QUERY_OFFLINE_CACHE') && !key.includes('RCTAsyncLocalStorage'), 135 | ); 136 | 137 | // Check if keys have changed (added/removed) 138 | const keysChanged = 139 | filteredKeys.length !== asyncStorageKeys.length || 140 | !filteredKeys.every((key) => asyncStorageKeys.includes(key)); 141 | 142 | if (keysChanged) { 143 | setAsyncStorageKeys([...filteredKeys]); // Convert readonly array to mutable array 144 | 145 | // Invalidate all AsyncStorage queries to refresh data 146 | queryClient.invalidateQueries({ 147 | queryKey: storageQueryKeys.async.root(), 148 | }); 149 | } else { 150 | // Keys are the same, but check if any values have changed 151 | for (const key of asyncStorageKeys) { 152 | try { 153 | // Check if the query exists in the cache first 154 | const queryExists = queryClient.getQueryCache().find({ queryKey: storageQueryKeys.async.key(key) }); 155 | 156 | // If query doesn't exist (e.g., after cache clear), skip comparison 157 | // The useQueries hook will recreate the query automatically 158 | if (!queryExists) { 159 | continue; 160 | } 161 | 162 | // Get current value from AsyncStorage 163 | const currentValue = await getAsyncStorageValue(key); 164 | 165 | // Get cached value from React Query 166 | const cachedData = queryClient.getQueryData(storageQueryKeys.async.key(key)); 167 | 168 | // Only compare if we have cached data (avoid false positives after cache clear) 169 | if (cachedData !== undefined) { 170 | // Compare values (deep comparison for objects) 171 | const valuesAreDifferent = JSON.stringify(currentValue) !== JSON.stringify(cachedData); 172 | 173 | if (valuesAreDifferent) { 174 | 175 | // Invalidate this specific query 176 | queryClient.invalidateQueries({ 177 | queryKey: storageQueryKeys.async.key(key), 178 | }); 179 | } 180 | } 181 | } catch (error) { 182 | console.error('📱 [AsyncStorage Hook] Error checking value for key:', key, error); 183 | } 184 | } 185 | } 186 | } catch (error) { 187 | console.error('📱 [AsyncStorage Hook] Error polling AsyncStorage keys:', error); 188 | } 189 | }, pollInterval); 190 | 191 | return () => { 192 | clearInterval(interval); 193 | }; 194 | }, [pollInterval, asyncStorageKeys, queryClient, getAsyncStorageValue, enabled, asyncStorage]); 195 | 196 | // Create individual queries for each key 197 | const queries = useQueries( 198 | { 199 | queries: 200 | enabled && asyncStorage 201 | ? asyncStorageKeys.map((key) => ({ 202 | queryKey: storageQueryKeys.async.key(key), 203 | queryFn: async () => { 204 | const value = await getAsyncStorageValue(key); 205 | return value; 206 | }, 207 | staleTime: pollInterval > 0 ? pollInterval / 2 : 0, // Half the poll interval 208 | gcTime: 5 * 60 * 1000, // 5 minutes 209 | networkMode: 'always' as const, 210 | retry: 0, // Retry failed requests 211 | retryDelay: 100, // 1 second delay between retries 212 | })) 213 | : [], 214 | combine: (results) => { 215 | if (!enabled || !asyncStorage) { 216 | return []; 217 | } 218 | 219 | const combinedResults = results.map((result, index) => ({ 220 | key: asyncStorageKeys[index], 221 | data: result.data, 222 | isLoading: result.isLoading, 223 | error: result.error, 224 | })); 225 | 226 | return combinedResults; 227 | }, 228 | }, 229 | queryClient, 230 | ); 231 | 232 | return queries; 233 | } 234 | -------------------------------------------------------------------------------- /src/react-query-external-sync/utils/storageHandlers.ts: -------------------------------------------------------------------------------- 1 | import type { QueryClient, QueryKey } from "@tanstack/react-query"; 2 | 3 | import { log } from "./logger"; 4 | 5 | /** 6 | * Storage interface that storage implementations should follow 7 | * Supports both MMKV-style (primitives + strings) and AsyncStorage-style (strings only) 8 | */ 9 | export interface StorageInterface { 10 | set: (key: string, value: string | number | boolean) => void | Promise; 11 | delete: (key: string) => void | Promise; 12 | } 13 | 14 | /** 15 | * Unified storage handler that works with both MMKV and AsyncStorage 16 | * This function updates the actual storage and then invalidates the React Query 17 | */ 18 | async function handleStorageOperation( 19 | queryKey: QueryKey, 20 | data: unknown, 21 | queryClient: QueryClient, 22 | storageKey: string, 23 | storage: StorageInterface, 24 | storageType: string, 25 | enableLogs?: boolean, 26 | deviceName?: string 27 | ): Promise { 28 | try { 29 | // Update the actual storage with the new data 30 | if (data === null || data === undefined) { 31 | // Delete the key if data is null/undefined 32 | await storage.delete(storageKey); 33 | } else if ( 34 | typeof data === "string" || 35 | typeof data === "number" || 36 | typeof data === "boolean" 37 | ) { 38 | // Handle primitives - both MMKV and AsyncStorage can handle these 39 | // (AsyncStorage will convert numbers/booleans to strings automatically) 40 | await storage.set(storageKey, data); 41 | } else { 42 | // For objects/arrays, JSON stringify for both storage types 43 | const jsonString = JSON.stringify(data); 44 | await storage.set(storageKey, jsonString); 45 | } 46 | 47 | // Manually invalidate the React Query since programmatic storage updates 48 | // don't trigger the change listener automatically 49 | queryClient.invalidateQueries({ queryKey }); 50 | } catch (error) { 51 | log( 52 | `❌ Failed to update ${storageType} storage: ${error}`, 53 | enableLogs || false, 54 | "error" 55 | ); 56 | // Fall back to just updating the query data if storage fails 57 | queryClient.setQueryData(queryKey, data, { 58 | updatedAt: Date.now(), 59 | }); 60 | } 61 | } 62 | 63 | /** 64 | * Unified storage removal handler that works with both MMKV and AsyncStorage 65 | * This function removes the key from actual storage and removes the query from React Query cache 66 | */ 67 | async function handleStorageRemovalOperation( 68 | queryKey: QueryKey, 69 | queryClient: QueryClient, 70 | storageKey: string, 71 | storage: StorageInterface, 72 | storageType: string, 73 | enableLogs?: boolean, 74 | deviceName?: string 75 | ): Promise { 76 | try { 77 | // Remove the key from actual storage 78 | await storage.delete(storageKey); 79 | 80 | // Remove the query from React Query cache 81 | queryClient.removeQueries({ queryKey, exact: true }); 82 | } catch (error) { 83 | log( 84 | `❌ Failed to remove ${storageType} storage key: ${error}`, 85 | enableLogs || false, 86 | "error" 87 | ); 88 | // Fall back to just removing the query from cache if storage fails 89 | queryClient.removeQueries({ queryKey, exact: true }); 90 | } 91 | } 92 | 93 | /** 94 | * Handles storage queries by detecting the storage type and delegating to the unified handler 95 | * This function assumes the queryKey is already confirmed to be a storage query 96 | * Expected format: ['#storage', 'storageType', 'key'] 97 | * Supported storage types: 'mmkv', 'asyncstorage', 'async-storage', 'async', 'securestorage', 'secure-storage', 'secure' 98 | * Returns true if it was handled, false if it should fall back to regular query update 99 | */ 100 | export function handleStorageUpdate( 101 | queryKey: QueryKey, 102 | data: unknown, 103 | queryClient: QueryClient, 104 | storage?: StorageInterface, 105 | enableLogs?: boolean, 106 | deviceName?: string 107 | ): boolean { 108 | const storageType = queryKey[1] as string; 109 | const storageKey = queryKey[2] as string; 110 | 111 | // Handle different storage types 112 | switch (storageType.toLowerCase()) { 113 | case "mmkv": 114 | if (!storage) { 115 | log( 116 | `⚠️ MMKV storage not configured for key: ${storageKey}`, 117 | enableLogs || false, 118 | "warn" 119 | ); 120 | return false; 121 | } 122 | // Use unified handler for MMKV 123 | handleStorageOperation( 124 | queryKey, 125 | data, 126 | queryClient, 127 | storageKey, 128 | storage, 129 | "MMKV", 130 | enableLogs, 131 | deviceName 132 | ).catch((error) => { 133 | log( 134 | `❌ MMKV storage update failed: ${error}`, 135 | enableLogs || false, 136 | "error" 137 | ); 138 | // Fall back to regular query update if storage fails 139 | queryClient.setQueryData(queryKey, data, { 140 | updatedAt: Date.now(), 141 | }); 142 | }); 143 | return true; 144 | case "asyncstorage": 145 | case "async-storage": 146 | case "async": 147 | if (!storage) { 148 | log( 149 | `⚠️ AsyncStorage not configured for key: ${storageKey}`, 150 | enableLogs || false, 151 | "warn" 152 | ); 153 | return false; 154 | } 155 | // Use unified handler for AsyncStorage 156 | handleStorageOperation( 157 | queryKey, 158 | data, 159 | queryClient, 160 | storageKey, 161 | storage, 162 | "AsyncStorage", 163 | enableLogs, 164 | deviceName 165 | ).catch((error) => { 166 | log( 167 | `❌ AsyncStorage update failed: ${error}`, 168 | enableLogs || false, 169 | "error" 170 | ); 171 | // Fall back to regular query update if storage fails 172 | queryClient.setQueryData(queryKey, data, { 173 | updatedAt: Date.now(), 174 | }); 175 | }); 176 | return true; 177 | case "securestorage": 178 | case "secure-storage": 179 | case "secure": 180 | if (!storage) { 181 | log( 182 | `⚠️ SecureStore not configured for key: ${storageKey}`, 183 | enableLogs || false, 184 | "warn" 185 | ); 186 | return false; 187 | } 188 | // Use unified handler for SecureStore 189 | handleStorageOperation( 190 | queryKey, 191 | data, 192 | queryClient, 193 | storageKey, 194 | storage, 195 | "SecureStore", 196 | enableLogs, 197 | deviceName 198 | ).catch((error) => { 199 | log( 200 | `❌ SecureStore update failed: ${error}`, 201 | enableLogs || false, 202 | "error" 203 | ); 204 | // Fall back to regular query update if storage fails 205 | queryClient.setQueryData(queryKey, data, { 206 | updatedAt: Date.now(), 207 | }); 208 | }); 209 | return true; 210 | default: 211 | // Unknown storage type, let the main function handle it as regular query 212 | return false; 213 | } 214 | } 215 | 216 | /** 217 | * Handles storage query removal by detecting the storage type and delegating to the unified removal handler 218 | * This function assumes the queryKey is already confirmed to be a storage query 219 | * Expected format: ['#storage', 'storageType', 'key'] 220 | * Supported storage types: 'mmkv', 'asyncstorage', 'async-storage', 'async', 'securestorage', 'secure-storage', 'secure' 221 | * Returns true if it was handled, false if it should fall back to regular query removal 222 | */ 223 | export function handleStorageRemoval( 224 | queryKey: QueryKey, 225 | queryClient: QueryClient, 226 | storage?: StorageInterface, 227 | enableLogs?: boolean, 228 | deviceName?: string 229 | ): boolean { 230 | const storageType = queryKey[1] as string; 231 | const storageKey = queryKey[2] as string; 232 | 233 | // Handle different storage types 234 | switch (storageType.toLowerCase()) { 235 | case "mmkv": 236 | if (!storage) { 237 | log( 238 | `⚠️ MMKV storage not configured for key: ${storageKey}`, 239 | enableLogs || false, 240 | "warn" 241 | ); 242 | return false; 243 | } 244 | // Use unified removal handler for MMKV 245 | handleStorageRemovalOperation( 246 | queryKey, 247 | queryClient, 248 | storageKey, 249 | storage, 250 | "MMKV", 251 | enableLogs, 252 | deviceName 253 | ).catch((error) => { 254 | log( 255 | `❌ MMKV storage removal failed: ${error}`, 256 | enableLogs || false, 257 | "error" 258 | ); 259 | // Fall back to regular query removal if storage fails 260 | queryClient.removeQueries({ queryKey, exact: true }); 261 | }); 262 | return true; 263 | case "asyncstorage": 264 | case "async-storage": 265 | case "async": 266 | if (!storage) { 267 | log( 268 | `⚠️ AsyncStorage not configured for key: ${storageKey}`, 269 | enableLogs || false, 270 | "warn" 271 | ); 272 | return false; 273 | } 274 | // Use unified removal handler for AsyncStorage 275 | handleStorageRemovalOperation( 276 | queryKey, 277 | queryClient, 278 | storageKey, 279 | storage, 280 | "AsyncStorage", 281 | enableLogs, 282 | deviceName 283 | ).catch((error) => { 284 | log( 285 | `❌ AsyncStorage removal failed: ${error}`, 286 | enableLogs || false, 287 | "error" 288 | ); 289 | // Fall back to regular query removal if storage fails 290 | queryClient.removeQueries({ queryKey, exact: true }); 291 | }); 292 | return true; 293 | case "securestorage": 294 | case "secure-storage": 295 | case "secure": 296 | if (!storage) { 297 | log( 298 | `⚠️ SecureStore not configured for key: ${storageKey}`, 299 | enableLogs || false, 300 | "warn" 301 | ); 302 | return false; 303 | } 304 | // Use unified removal handler for SecureStore 305 | handleStorageRemovalOperation( 306 | queryKey, 307 | queryClient, 308 | storageKey, 309 | storage, 310 | "SecureStore", 311 | enableLogs, 312 | deviceName 313 | ).catch((error) => { 314 | log( 315 | `❌ SecureStore removal failed: ${error}`, 316 | enableLogs || false, 317 | "error" 318 | ); 319 | // Fall back to regular query removal if storage fails 320 | queryClient.removeQueries({ queryKey, exact: true }); 321 | }); 322 | return true; 323 | default: 324 | // Unknown storage type, let the main function handle it as regular query 325 | return false; 326 | } 327 | } 328 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Query External Sync 2 | 3 | A powerful debugging tool for React Query state and device storage in any React-based application. Whether you're building for mobile, web, desktop, TV, or VR - this package has you covered. It works seamlessly across all platforms where React runs, with zero configuration to disable in production. 4 | 5 | Pairs perfectly with [React Native DevTools](https://github.com/LovesWorking/rn-better-dev-tools) for a complete development experience. 6 | 7 | ![React Query External Sync Demo](https://github.com/user-attachments/assets/39e5c417-be4d-46af-8138-3589d73fce9f) 8 | 9 | ### If you need internal React Query dev tools within the device you can use my other package here! 10 | 11 | https://github.com/LovesWorking/react-native-react-query-devtools 12 | 13 | ## ✨ Features 14 | 15 | - 🔄 Real-time React Query state synchronization 16 | - 💾 **Device storage monitoring with CRUD operations** - MMKV, AsyncStorage, and SecureStorage 17 | - 📱 Works with any React-based framework (React, React Native, Expo, Next.js, etc.) 18 | - 🖥️ Platform-agnostic: Web, iOS, Android, macOS, Windows, Linux, tvOS, VR - you name it! 19 | - 🔌 Socket.IO integration for reliable communication 20 | - 📊 Query status, data, and error monitoring 21 | - 🏷️ Device type detection (real device vs simulator/emulator) 22 | - ⚡️ Simple integration with minimal setup 23 | - 🧩 Perfect companion to React Native DevTools 24 | - 🛑 Zero-config production safety - automatically disabled in production builds 25 | 26 | ## 📦 Installation 27 | 28 | ```bash 29 | # Using npm 30 | npm install --save-dev react-query-external-sync socket.io-client 31 | npm install expo-device # For automatic device detection 32 | 33 | # Using yarn 34 | yarn add -D react-query-external-sync socket.io-client 35 | yarn add expo-device # For automatic device detection 36 | 37 | # Using pnpm 38 | pnpm add -D react-query-external-sync socket.io-client 39 | pnpm add expo-device # For automatic device detection 40 | ``` 41 | 42 | ## 🚀 Quick Start 43 | 44 | Add the hook to your application where you set up your React Query context: 45 | 46 | ```jsx 47 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 48 | import { useSyncQueriesExternal } from "react-query-external-sync"; 49 | import { Platform } from "react-native"; 50 | import AsyncStorage from "@react-native-async-storage/async-storage"; 51 | import * as SecureStore from "expo-secure-store"; 52 | import * as ExpoDevice from "expo-device"; 53 | import { storage } from "./mmkv"; // Your MMKV instance 54 | 55 | // Create your query client 56 | const queryClient = new QueryClient(); 57 | 58 | function App() { 59 | return ( 60 | 61 | 62 | 63 | ); 64 | } 65 | 66 | function AppContent() { 67 | // Unified storage queries and external sync - all in one hook! 68 | useSyncQueriesExternal({ 69 | queryClient, 70 | socketURL: "http://localhost:42831", 71 | deviceName: Platform.OS, 72 | platform: Platform.OS, 73 | deviceId: Platform.OS, 74 | isDevice: ExpoDevice.isDevice, // Automatically detects real devices vs emulators 75 | extraDeviceInfo: { 76 | appVersion: "1.0.0", 77 | }, 78 | enableLogs: true, 79 | envVariables: { 80 | NODE_ENV: process.env.NODE_ENV, 81 | }, 82 | // Storage monitoring with CRUD operations 83 | mmkvStorage: storage, // MMKV storage for ['#storage', 'mmkv', 'key'] queries + monitoring 84 | asyncStorage: AsyncStorage, // AsyncStorage for ['#storage', 'async', 'key'] queries + monitoring 85 | secureStorage: SecureStore, // SecureStore for ['#storage', 'secure', 'key'] queries + monitoring 86 | secureStorageKeys: [ 87 | "userToken", 88 | "refreshToken", 89 | "biometricKey", 90 | "deviceId", 91 | ], // SecureStore keys to monitor 92 | }); 93 | 94 | // Your app content 95 | return ; 96 | } 97 | ``` 98 | 99 | ## 🔒 Production Safety 100 | 101 | This package is automatically disabled in production builds. 102 | 103 | ```jsx 104 | // The package handles this internally: 105 | if (process.env.NODE_ENV !== "production") { 106 | useSyncQueries = require("./new-sync/useSyncQueries").useSyncQueries; 107 | } else { 108 | // In production, this becomes a no-op function 109 | useSyncQueries = () => ({ 110 | isConnected: false, 111 | connect: () => {}, 112 | disconnect: () => {}, 113 | socket: null, 114 | users: [], 115 | }); 116 | } 117 | ``` 118 | 119 | ## 💡 Usage with DevTools 120 | 121 | For the best experience, use this package with the [React Native DevTools](https://github.com/LovesWorking/rn-better-dev-tools) application: 122 | 123 | 1. Download and launch the DevTools application 124 | 2. Integrate this package in your React application 125 | 3. Start your application 126 | 4. DevTools will automatically detect and connect to your running application 127 | 128 | > **Note**: For optimal connection, launch DevTools before starting your application. 129 | 130 | ## ⚙️ Configuration Options 131 | 132 | The `useSyncQueriesExternal` hook accepts the following options: 133 | 134 | | Option | Type | Required | Description | 135 | | ------------------- | ------------ | -------- | ----------------------------------------------------------------------- | 136 | | `queryClient` | QueryClient | Yes | Your React Query client instance | 137 | | `socketURL` | string | Yes | URL of the socket server (e.g., 'http://localhost:42831') | 138 | | `deviceName` | string | Yes | Human-readable name for your device | 139 | | `platform` | string | Yes | Platform identifier ('ios', 'android', 'web', 'macos', 'windows', etc.) | 140 | | `deviceId` | string | Yes | Unique identifier for your device | 141 | | `extraDeviceInfo` | object | No | Additional device metadata to display in DevTools | 142 | | `enableLogs` | boolean | No | Enable console logging for debugging (default: false) | 143 | | `isDevice` | boolean | No | Specify if this is a real device vs simulator/emulator (default: false) | 144 | | `envVariables` | object | No | Environment variables to sync with DevTools | 145 | | `mmkvStorage` | MmkvStorage | No | MMKV storage instance for real-time monitoring | 146 | | `asyncStorage` | AsyncStorage | No | AsyncStorage instance for polling-based monitoring | 147 | | `secureStorage` | SecureStore | No | SecureStore instance for secure data monitoring | 148 | | `secureStorageKeys` | string[] | No | Array of SecureStore keys to monitor (required if using secureStorage) | 149 | 150 | ## 🐛 Troubleshooting 151 | 152 | ### Quick Checklist 153 | 154 | 1. **DevTools Connection** 155 | 156 | - Look for "Connected" status in the top-left corner of the DevTools app 157 | - If it shows "Disconnected", restart the DevTools app 158 | 159 | 2. **No Devices Appearing** 160 | 161 | - Verify the Socket.IO client is installed (`npm list socket.io-client`) 162 | - Ensure the hook is properly set up in your app 163 | - Check that `socketURL` matches the DevTools port (default: 42831) 164 | - Restart both your app and the DevTools 165 | 166 | 3. **Data Not Syncing** 167 | 168 | - Confirm you're passing the correct `queryClient` instance 169 | - Set `enableLogs: true` to see connection information 170 | 171 | 4. **Android Real Device Connection Issues** 172 | - If using a real Android device with React Native CLI and ADB, ensure `isDevice: true` 173 | - The package transforms `localhost` to `10.0.2.2` for emulators only 174 | - Use `ExpoDevice.isDevice` for automatic detection: `import * as ExpoDevice from "expo-device"` 175 | - Check network connectivity between your device and development machine 176 | 177 | That's it! If you're still having issues, visit the [GitHub repository](https://github.com/LovesWorking/react-query-external-sync/issues) for support. 178 | 179 | ## 🏷️ Device Type Detection 180 | 181 | The `isDevice` prop helps the DevTools distinguish between real devices and simulators/emulators. This is **crucial for Android connectivity** - the package automatically handles URL transformation for Android emulators (localhost → 10.0.2.2) but needs to know if you're running on a real device to avoid this transformation. 182 | 183 | ### ⚠️ Android Connection Issue 184 | 185 | On real Android devices using React Native CLI and ADB, the automatic emulator detection can incorrectly transform `localhost` to `10.0.2.2`, breaking WebSocket connections. Setting `isDevice: true` prevents this transformation. 186 | 187 | **Recommended approaches:** 188 | 189 | ```jsx 190 | // Best approach using Expo Device (works with bare React Native too) 191 | import * as ExpoDevice from "expo-device"; 192 | 193 | useSyncQueriesExternal({ 194 | queryClient, 195 | socketURL: "http://localhost:42831", 196 | deviceName: Platform.OS, 197 | platform: Platform.OS, 198 | deviceId: Platform.OS, 199 | isDevice: ExpoDevice.isDevice, // Automatically detects real devices vs emulators 200 | // ... other props 201 | }); 202 | 203 | // Alternative: Simple approach using React Native's __DEV__ flag 204 | isDevice: !__DEV__, // true for production/real devices, false for development/simulators 205 | 206 | // Alternative: More sophisticated detection using react-native-device-info 207 | import DeviceInfo from 'react-native-device-info'; 208 | isDevice: !DeviceInfo.isEmulator(), // Automatically detects if running on emulator 209 | 210 | // Manual control for specific scenarios 211 | isDevice: Platform.OS === 'ios' ? !Platform.isPad : Platform.OS !== 'web', 212 | ``` 213 | 214 | ## ⚠️ Important Note About Device IDs 215 | 216 | The `deviceId` parameter must be **persistent** across app restarts and re-renders. Using a value that changes (like `Date.now()`) will cause each render to be treated as a new device. 217 | 218 | **Recommended approaches:** 219 | 220 | ```jsx 221 | // Simple approach for single devices 222 | deviceId: Platform.OS, // Works if you only have one device per platform 223 | 224 | // Better approach for multiple simulators/devices of same type 225 | // Using AsyncStorage, MMKV, or another storage solution 226 | const [deviceId, setDeviceId] = useState(Platform.OS); 227 | 228 | useEffect(() => { 229 | const loadOrCreateDeviceId = async () => { 230 | // Try to load existing ID 231 | const storedId = await AsyncStorage.getItem('deviceId'); 232 | 233 | if (storedId) { 234 | setDeviceId(storedId); 235 | } else { 236 | // First launch - generate and store a persistent ID 237 | const newId = `${Platform.OS}-${Date.now()}`; 238 | await AsyncStorage.setItem('deviceId', newId); 239 | setDeviceId(newId); 240 | } 241 | }; 242 | 243 | loadOrCreateDeviceId(); 244 | }, []); 245 | ``` 246 | 247 | ## 📄 License 248 | 249 | MIT 250 | 251 | --- 252 | 253 | Made with ❤️ by [LovesWorking](https://github.com/LovesWorking) 254 | 255 | 256 | ## 🚀 More 257 | 258 | **Take a shortcut from web developer to mobile development fluency with guided learning** 259 | 260 | Enjoyed this project? Learn to use React Native to build production-ready, native mobile apps for both iOS and Android based on your existing web development skills. 261 | 262 | banner 263 | 264 | -------------------------------------------------------------------------------- /src/react-query-external-sync/utils/logger.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Log types supported by the logger 3 | */ 4 | export type LogType = "log" | "warn" | "error"; 5 | 6 | /** 7 | * Helper function for controlled logging 8 | * Only shows logs when enableLogs is true 9 | * Always shows warnings and errors regardless of enableLogs setting 10 | */ 11 | export function log( 12 | message: string, 13 | enableLogs: boolean, 14 | type: LogType = "log" 15 | ): void { 16 | if (!enableLogs) return; 17 | switch (type) { 18 | case "warn": 19 | console.warn(message); 20 | break; 21 | case "error": 22 | console.error(message); 23 | break; 24 | default: 25 | console.log(message); 26 | } 27 | } 28 | 29 | /** 30 | * Context for sync operations 31 | */ 32 | interface SyncContext { 33 | deviceName: string; 34 | deviceId: string; 35 | platform: string; 36 | requestId: string; 37 | timestamp: number; 38 | } 39 | 40 | /** 41 | * Stats for tracking sync operations 42 | */ 43 | interface SyncOperationStats { 44 | storageUpdates: { 45 | mmkv: number; 46 | asyncStorage: number; 47 | secureStore: number; 48 | }; 49 | queryActions: { 50 | dataUpdates: number; 51 | refetches: number; 52 | invalidations: number; 53 | resets: number; 54 | removes: number; 55 | errors: number; 56 | }; 57 | connectionEvents: { 58 | connects: number; 59 | disconnects: number; 60 | reconnects: number; 61 | }; 62 | errors: Array<{ 63 | type: string; 64 | message: string; 65 | timestamp: number; 66 | }>; 67 | } 68 | 69 | /** 70 | * Sync logger for external sync operations 71 | * Provides clean, grouped logging similar to API route logging 72 | */ 73 | class ExternalSyncLogger { 74 | private operations = new Map< 75 | string, 76 | { 77 | context: SyncContext; 78 | startTime: number; 79 | stats: SyncOperationStats; 80 | enableLogs: boolean; 81 | } 82 | >(); 83 | 84 | /** 85 | * Start a new sync operation 86 | */ 87 | startOperation( 88 | type: "connection" | "query-action" | "storage-update" | "sync-session", 89 | context: Partial, 90 | enableLogs: boolean = false 91 | ): string { 92 | const requestId = this.generateRequestId(); 93 | const fullContext: SyncContext = { 94 | deviceName: context.deviceName || "unknown", 95 | deviceId: context.deviceId || "unknown", 96 | platform: context.platform || "unknown", 97 | requestId, 98 | timestamp: Date.now(), 99 | }; 100 | 101 | this.operations.set(requestId, { 102 | context: fullContext, 103 | startTime: Date.now(), 104 | stats: { 105 | storageUpdates: { mmkv: 0, asyncStorage: 0, secureStore: 0 }, 106 | queryActions: { 107 | dataUpdates: 0, 108 | refetches: 0, 109 | invalidations: 0, 110 | resets: 0, 111 | removes: 0, 112 | errors: 0, 113 | }, 114 | connectionEvents: { connects: 0, disconnects: 0, reconnects: 0 }, 115 | errors: [], 116 | }, 117 | enableLogs, 118 | }); 119 | 120 | if (enableLogs) { 121 | const icon = this.getOperationIcon(type); 122 | const readableTime = new Date(fullContext.timestamp).toLocaleString( 123 | "en-US", 124 | { 125 | month: "short", 126 | day: "numeric", 127 | hour: "2-digit", 128 | minute: "2-digit", 129 | second: "2-digit", 130 | hour12: true, 131 | } 132 | ); 133 | 134 | log( 135 | `┌─ 🌴 ${this.getOperationTitle(type)} • ${fullContext.deviceName} (${ 136 | fullContext.platform 137 | }) • ${readableTime}`, 138 | enableLogs 139 | ); 140 | } 141 | 142 | return requestId; 143 | } 144 | 145 | /** 146 | * Log a storage update 147 | */ 148 | logStorageUpdate( 149 | requestId: string, 150 | storageType: "mmkv" | "asyncStorage" | "secureStore", 151 | key: string, 152 | currentValue: unknown, 153 | newValue: unknown 154 | ): void { 155 | const operation = this.operations.get(requestId); 156 | if (!operation) return; 157 | 158 | operation.stats.storageUpdates[storageType]++; 159 | 160 | if (operation.enableLogs) { 161 | const icon = this.getStorageIcon(storageType); 162 | const typeDisplay = 163 | storageType === "asyncStorage" 164 | ? "AsyncStorage" 165 | : storageType === "secureStore" 166 | ? "SecureStore" 167 | : "MMKV"; 168 | log( 169 | `├─ ${icon} ${typeDisplay}: ${key} | ${JSON.stringify( 170 | currentValue 171 | )} → ${JSON.stringify(newValue)}`, 172 | operation.enableLogs 173 | ); 174 | } 175 | } 176 | 177 | /** 178 | * Log a query action 179 | */ 180 | logQueryAction( 181 | requestId: string, 182 | action: string, 183 | queryHash: string, 184 | success: boolean = true 185 | ): void { 186 | const operation = this.operations.get(requestId); 187 | if (!operation) return; 188 | 189 | // Update stats based on action type 190 | switch (action) { 191 | case "ACTION-DATA-UPDATE": 192 | operation.stats.queryActions.dataUpdates++; 193 | break; 194 | case "ACTION-REFETCH": 195 | operation.stats.queryActions.refetches++; 196 | break; 197 | case "ACTION-INVALIDATE": 198 | operation.stats.queryActions.invalidations++; 199 | break; 200 | case "ACTION-RESET": 201 | operation.stats.queryActions.resets++; 202 | break; 203 | case "ACTION-REMOVE": 204 | operation.stats.queryActions.removes++; 205 | break; 206 | default: 207 | if (!success) operation.stats.queryActions.errors++; 208 | break; 209 | } 210 | 211 | if (operation.enableLogs) { 212 | const icon = this.getActionIcon(action, success); 213 | const actionName = this.getActionDisplayName(action); 214 | log(`├─ ${icon} ${actionName}: ${queryHash}`, operation.enableLogs); 215 | } 216 | } 217 | 218 | /** 219 | * Log a connection event 220 | */ 221 | logConnectionEvent( 222 | requestId: string, 223 | event: "connect" | "disconnect" | "reconnect", 224 | details?: string 225 | ): void { 226 | const operation = this.operations.get(requestId); 227 | if (!operation) return; 228 | 229 | operation.stats.connectionEvents[ 230 | `${event}s` as keyof typeof operation.stats.connectionEvents 231 | ]++; 232 | 233 | if (operation.enableLogs) { 234 | const icon = 235 | event === "connect" ? "🔗" : event === "disconnect" ? "🔌" : "🔄"; 236 | const message = details 237 | ? `${event.toUpperCase()}: ${details}` 238 | : event.toUpperCase(); 239 | console.log(`├ ${icon} ${message}`); 240 | } 241 | } 242 | 243 | /** 244 | * Log an error 245 | */ 246 | logError( 247 | requestId: string, 248 | type: string, 249 | message: string, 250 | error?: Error 251 | ): void { 252 | const operation = this.operations.get(requestId); 253 | if (!operation) return; 254 | 255 | operation.stats.errors.push({ 256 | type, 257 | message, 258 | timestamp: Date.now(), 259 | }); 260 | 261 | if (operation.enableLogs) { 262 | console.log(`├ ❌ ${type}: ${message}`); 263 | if (error?.stack) { 264 | console.log(`├ Stack: ${error.stack.split("\n")[1]?.trim()}`); 265 | } 266 | } 267 | } 268 | 269 | /** 270 | * Complete and log the operation summary 271 | */ 272 | completeOperation(requestId: string, success: boolean = true): void { 273 | const operation = this.operations.get(requestId); 274 | if (!operation) return; 275 | 276 | // Only show completion log if there was an error 277 | if (operation.enableLogs && !success) { 278 | log(`└─ ❌ Error`, operation.enableLogs); 279 | log("", operation.enableLogs); // Add empty line for spacing 280 | } else if (operation.enableLogs) { 281 | // Just add spacing for successful operations without the "Complete" message 282 | log("", operation.enableLogs); 283 | } 284 | 285 | // Clean up without logging summary 286 | this.operations.delete(requestId); 287 | } 288 | 289 | private generateRequestId(): string { 290 | return Math.random().toString(36).substring(2, 15); 291 | } 292 | 293 | private getOperationIcon(type: string): string { 294 | switch (type) { 295 | case "connection": 296 | return "🔗"; 297 | case "query-action": 298 | return "🔄"; 299 | case "storage-update": 300 | return "💾"; 301 | case "sync-session": 302 | return "🔄"; 303 | default: 304 | return "📋"; 305 | } 306 | } 307 | 308 | private getOperationTitle(type: string): string { 309 | switch (type) { 310 | case "connection": 311 | return "Connection"; 312 | case "query-action": 313 | return "Query Action"; 314 | case "storage-update": 315 | return "Storage Update"; 316 | case "sync-session": 317 | return "Sync Session"; 318 | default: 319 | return "Operation"; 320 | } 321 | } 322 | 323 | private getStorageIcon(storageType: string): string { 324 | switch (storageType) { 325 | case "mmkv": 326 | return "💾"; 327 | case "asyncStorage": 328 | return "📱"; 329 | case "secureStore": 330 | return "🔐"; 331 | default: 332 | return "📦"; 333 | } 334 | } 335 | 336 | private getActionIcon(action: string, success: boolean): string { 337 | if (!success) return "🔴"; // Red for failures (#EF4444) 338 | 339 | switch (action) { 340 | case "ACTION-DATA-UPDATE": 341 | return "🟢"; // Green for fresh/success (#039855) 342 | case "ACTION-REFETCH": 343 | return "🔵"; // Blue for refetch (#1570EF) 344 | case "ACTION-INVALIDATE": 345 | return "🟠"; // Orange for invalidate (#DC6803) 346 | case "ACTION-RESET": 347 | return "⚫"; // Dark gray for reset (#475467) 348 | case "ACTION-REMOVE": 349 | return "🟣"; // Pink/purple for remove (#DB2777) 350 | case "ACTION-TRIGGER-ERROR": 351 | return "🔴"; // Red for error (#EF4444) 352 | case "ACTION-RESTORE-ERROR": 353 | return "🟢"; // Green for restore (success variant) 354 | case "ACTION-TRIGGER-LOADING": 355 | return "🔷"; // Light blue diamond for loading (#0891B2) 356 | case "ACTION-RESTORE-LOADING": 357 | return "🔶"; // Orange diamond for restore loading (loading variant) 358 | case "ACTION-CLEAR-MUTATION-CACHE": 359 | return "⚪"; // White for clear cache (neutral) 360 | case "ACTION-CLEAR-QUERY-CACHE": 361 | return "⬜"; // White square for clear cache (neutral) 362 | case "ACTION-ONLINE-MANAGER-ONLINE": 363 | return "🟢"; // Green for online (fresh) 364 | case "ACTION-ONLINE-MANAGER-OFFLINE": 365 | return "🔴"; // Red for offline (error) 366 | default: 367 | return "⚪"; // White for generic (inactive #667085) 368 | } 369 | } 370 | 371 | private getActionDisplayName(action: string): string { 372 | switch (action) { 373 | case "ACTION-DATA-UPDATE": 374 | return "Data Update"; 375 | case "ACTION-REFETCH": 376 | return "Refetch"; 377 | case "ACTION-INVALIDATE": 378 | return "Invalidate"; 379 | case "ACTION-RESET": 380 | return "Reset"; 381 | case "ACTION-REMOVE": 382 | return "Remove"; 383 | case "ACTION-TRIGGER-ERROR": 384 | return "Trigger Error"; 385 | case "ACTION-RESTORE-ERROR": 386 | return "Restore Error"; 387 | case "ACTION-TRIGGER-LOADING": 388 | return "Trigger Loading"; 389 | case "ACTION-RESTORE-LOADING": 390 | return "Restore Loading"; 391 | default: 392 | return action.replace("ACTION-", "").replace(/-/g, " "); 393 | } 394 | } 395 | 396 | private formatDuration(ms: number): string { 397 | if (ms < 1000) return `${ms}ms`; 398 | const seconds = (ms / 1000).toFixed(1); 399 | return `${seconds}s`; 400 | } 401 | } 402 | 403 | export const syncLogger = new ExternalSyncLogger(); 404 | -------------------------------------------------------------------------------- /src/react-query-external-sync/hooks/useDynamicSecureStorageQueries.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; 2 | import { QueryClient, useQueries } from '@tanstack/react-query'; 3 | 4 | import { storageQueryKeys } from './storageQueryKeys'; 5 | 6 | /** 7 | * SecureStore interface that matches expo-secure-store API 8 | * Users can pass any implementation that follows this interface 9 | */ 10 | export interface SecureStoreStatic { 11 | getItemAsync: (key: string) => Promise; 12 | setItemAsync?: (key: string, value: string) => Promise; 13 | deleteItemAsync?: (key: string) => Promise; 14 | } 15 | 16 | export interface UseDynamicSecureStorageQueriesOptions { 17 | /** 18 | * The React Query client instance 19 | */ 20 | queryClient: QueryClient; 21 | /** 22 | * SecureStore instance that implements the SecureStore interface 23 | * This allows users to provide their own SecureStore implementation 24 | * (e.g., expo-secure-store, react-native-keychain, or custom implementation) 25 | */ 26 | secureStorage?: SecureStoreStatic; 27 | /** 28 | * Optional interval in milliseconds to poll for value changes 29 | * Defaults to 1000ms (1 second). Set to 0 to disable polling. 30 | * Note: SecureStore doesn't provide getAllKeys() for security reasons, 31 | * so we only poll known keys for value changes. 32 | */ 33 | pollInterval?: number; 34 | /** 35 | * Array of known SecureStore keys to monitor 36 | * Since SecureStore doesn't expose getAllKeys() for security reasons, 37 | * you must provide the keys you want to monitor 38 | */ 39 | knownKeys: string[]; 40 | } 41 | 42 | export interface SecureStorageQueryResult { 43 | key: string; 44 | data: unknown; 45 | isLoading: boolean; 46 | error: Error | null; 47 | } 48 | 49 | /** 50 | * Hook that creates individual React Query queries for each SecureStore key 51 | * This gives you granular control and better performance since each key has its own query 52 | * Since SecureStore doesn't have built-in change listeners and doesn't expose getAllKeys(), 53 | * this hook uses polling to detect value changes for provided known keys 54 | * 55 | * @example 56 | * // With expo-secure-store 57 | * import * as SecureStore from 'expo-secure-store'; 58 | * 59 | * const queries = useDynamicSecureStorageQueries({ 60 | * queryClient, 61 | * secureStorage: SecureStore, 62 | * knownKeys: ['auth.session', 'auth.email', 'sessionToken', 'knock_push_token'] 63 | * }); 64 | * 65 | * @example 66 | * // With react-native-keychain or custom implementation 67 | * const customSecureStore = { 68 | * getItemAsync: async (key: string) => { 69 | * // Your custom implementation 70 | * return await Keychain.getInternetCredentials(key); 71 | * } 72 | * }; 73 | * 74 | * const queries = useDynamicSecureStorageQueries({ 75 | * queryClient, 76 | * secureStorage: customSecureStore, 77 | * knownKeys: ['auth.session', 'auth.email'] 78 | * }); 79 | * 80 | * // Returns: [ 81 | * // { key: 'auth.session', data: { user: {...} }, isLoading: false, error: null }, 82 | * // { key: 'auth.email', data: 'user@example.com', isLoading: false, error: null }, 83 | * // ... 84 | * // ] 85 | */ 86 | export function useDynamicSecureStorageQueries({ 87 | queryClient, 88 | secureStorage, 89 | pollInterval = 1000, 90 | knownKeys, 91 | }: UseDynamicSecureStorageQueriesOptions): SecureStorageQueryResult[] { 92 | // State to track which keys actually exist in SecureStore 93 | const [existingKeys, setExistingKeys] = useState([]); 94 | 95 | // Use ref to track the current keys to avoid stale closures in polling 96 | const existingKeysRef = useRef([]); 97 | 98 | // Track if we're currently checking keys to prevent concurrent checks 99 | const isCheckingKeysRef = useRef(false); 100 | 101 | // Update ref whenever existingKeys changes 102 | useEffect(() => { 103 | existingKeysRef.current = existingKeys; 104 | }, [existingKeys]); 105 | 106 | // Helper function to get a single SecureStore value 107 | const getSecureStorageValue = useCallback( 108 | async (key: string): Promise => { 109 | if (!secureStorage) { 110 | throw new Error('SecureStorage instance not provided'); 111 | } 112 | 113 | try { 114 | const value = await secureStorage.getItemAsync(key); 115 | if (value === null) { 116 | return null; 117 | } 118 | 119 | // Try to parse as JSON, fall back to string 120 | try { 121 | return JSON.parse(value); 122 | } catch { 123 | return value; 124 | } 125 | } catch (error) { 126 | console.error('Error getting SecureStore value for key:', key, error); 127 | throw error; 128 | } 129 | }, 130 | [secureStorage], 131 | ); 132 | 133 | // Helper function to compare arrays 134 | const arraysEqual = useCallback((a: string[], b: string[]): boolean => { 135 | if (a.length !== b.length) return false; 136 | const sortedA = [...a].sort(); 137 | const sortedB = [...b].sort(); 138 | return sortedA.every((val, index) => val === sortedB[index]); 139 | }, []); 140 | 141 | // Function to check which known keys actually exist in SecureStore 142 | const refreshExistingKeys = useCallback(async (): Promise => { 143 | if (!secureStorage || isCheckingKeysRef.current) { 144 | return; 145 | } 146 | 147 | isCheckingKeysRef.current = true; 148 | 149 | try { 150 | const existingKeysList: string[] = []; 151 | 152 | // Check each known key to see if it exists 153 | await Promise.all( 154 | knownKeys.map(async (key) => { 155 | try { 156 | const value = await secureStorage.getItemAsync(key); 157 | if (value !== null) { 158 | existingKeysList.push(key); 159 | } 160 | } catch (error) { 161 | console.error('🔑 [SecureStore Hook] Error checking key:', key, error); 162 | } 163 | }), 164 | ); 165 | 166 | // Only update state if the keys have actually changed 167 | const currentKeys = existingKeysRef.current; 168 | if (!arraysEqual(existingKeysList, currentKeys)) { 169 | setExistingKeys([...existingKeysList]); 170 | } 171 | } catch (error) { 172 | console.error('🔑 [SecureStore Hook] Error checking SecureStore keys:', error); 173 | } finally { 174 | isCheckingKeysRef.current = false; 175 | } 176 | }, [knownKeys, arraysEqual, secureStorage]); 177 | 178 | // Initial load of existing keys 179 | useEffect(() => { 180 | if (secureStorage) { 181 | refreshExistingKeys(); 182 | } 183 | }, [refreshExistingKeys, secureStorage]); 184 | 185 | // Set up polling for value changes (since SecureStore doesn't have listeners) 186 | useEffect(() => { 187 | if (!secureStorage || pollInterval <= 0) { 188 | return; 189 | } 190 | 191 | const interval = setInterval(async () => { 192 | try { 193 | const currentExistingKeys = existingKeysRef.current; 194 | 195 | // Skip if we're already checking keys 196 | if (isCheckingKeysRef.current) { 197 | return; 198 | } 199 | 200 | // Check if any known keys have been added or removed 201 | const newExistingKeys: string[] = []; 202 | await Promise.all( 203 | knownKeys.map(async (key) => { 204 | try { 205 | const value = await secureStorage.getItemAsync(key); 206 | if (value !== null) { 207 | newExistingKeys.push(key); 208 | } 209 | } catch (error) { 210 | console.error('🔑 [SecureStore Hook] Error checking key during poll:', key, error); 211 | } 212 | }), 213 | ); 214 | 215 | // Check if keys have changed (added/removed) using proper comparison 216 | const keysChanged = !arraysEqual(newExistingKeys, currentExistingKeys); 217 | 218 | if (keysChanged) { 219 | console.log('🔄 [SecureStore Hook] SecureStore keys changed!'); 220 | console.log('🔄 [SecureStore Hook] Old keys:', currentExistingKeys.length); 221 | console.log('🔄 [SecureStore Hook] New keys:', newExistingKeys.length); 222 | setExistingKeys([...newExistingKeys]); 223 | 224 | // Invalidate all SecureStore queries to refresh data 225 | queryClient.invalidateQueries({ 226 | queryKey: storageQueryKeys.secure.root(), 227 | }); 228 | } else { 229 | // Keys are the same, but check if any values have changed 230 | for (const key of currentExistingKeys) { 231 | try { 232 | // Check if the query exists in the cache first 233 | const queryExists = queryClient.getQueryCache().find({ queryKey: storageQueryKeys.secure.key(key) }); 234 | 235 | // If query doesn't exist (e.g., after cache clear), skip comparison 236 | // The useQueries hook will recreate the query automatically 237 | if (!queryExists) { 238 | continue; 239 | } 240 | 241 | // Get current value from SecureStore 242 | const currentValue = await getSecureStorageValue(key); 243 | 244 | // Get cached value from React Query 245 | const cachedData = queryClient.getQueryData(storageQueryKeys.secure.key(key)); 246 | 247 | // Only compare if we have cached data (avoid false positives after cache clear) 248 | if (cachedData !== undefined) { 249 | // Deep comparison using a more robust method 250 | const valuesAreDifferent = !deepEqual(currentValue, cachedData); 251 | 252 | if (valuesAreDifferent) { 253 | console.log('🔄 [SecureStore Hook] Value changed for key:', key); 254 | 255 | // Invalidate this specific query 256 | queryClient.invalidateQueries({ 257 | queryKey: storageQueryKeys.secure.key(key), 258 | }); 259 | } 260 | } 261 | } catch (error) { 262 | console.error('🔑 [SecureStore Hook] Error checking value for key:', key, error); 263 | } 264 | } 265 | } 266 | } catch (error) { 267 | console.error('🔑 [SecureStore Hook] Error polling SecureStore keys:', error); 268 | } 269 | }, pollInterval); 270 | 271 | return () => { 272 | clearInterval(interval); 273 | }; 274 | }, [pollInterval, knownKeys, queryClient, getSecureStorageValue, arraysEqual, secureStorage]); 275 | 276 | // Create individual queries for each existing key 277 | const queries = useQueries( 278 | { 279 | queries: existingKeys.map((key) => ({ 280 | queryKey: storageQueryKeys.secure.key(key), 281 | queryFn: async () => { 282 | const value = await getSecureStorageValue(key); 283 | return value; 284 | }, 285 | staleTime: pollInterval > 0 ? pollInterval / 2 : 0, // Half the poll interval 286 | gcTime: 10 * 60 * 1000, // 10 minutes (longer than AsyncStorage for security) 287 | networkMode: 'always' as const, 288 | retry: 1, // Retry once for secure storage 289 | retryDelay: 200, // 200ms delay between retries 290 | })), 291 | combine: (results) => { 292 | const combinedResults = results.map((result, index) => ({ 293 | key: existingKeys[index], 294 | data: result.data, 295 | isLoading: result.isLoading, 296 | error: result.error, 297 | })); 298 | 299 | return combinedResults; 300 | }, 301 | }, 302 | queryClient, 303 | ); 304 | 305 | // Return empty array if no secureStorage is provided 306 | if (!secureStorage) { 307 | return []; 308 | } 309 | 310 | return queries; 311 | } 312 | 313 | // Helper function for deep equality comparison 314 | function deepEqual(a: unknown, b: unknown): boolean { 315 | if (a === b) return true; 316 | 317 | if (a === null || b === null || a === undefined || b === undefined) { 318 | return a === b; 319 | } 320 | 321 | if (typeof a !== typeof b) return false; 322 | 323 | if (typeof a !== 'object') return a === b; 324 | 325 | // For objects, use JSON comparison as fallback but handle edge cases 326 | try { 327 | return JSON.stringify(a) === JSON.stringify(b); 328 | } catch { 329 | return false; 330 | } 331 | } 332 | -------------------------------------------------------------------------------- /src/react-query-external-sync/useSyncQueriesExternal.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useRef } from "react"; 2 | import type { QueryKey } from "@tanstack/query-core"; 3 | import { onlineManager, QueryClient } from "@tanstack/react-query"; 4 | 5 | import { 6 | type AsyncStorageStatic, 7 | useDynamicAsyncStorageQueries, 8 | } from "./hooks/useDynamicAsyncStorageQueries"; 9 | import { 10 | MmkvStorage, 11 | useDynamicMmkvQueries, 12 | } from "./hooks/useDynamicMmkvQueries"; 13 | import { useDynamicSecureStorageQueries } from "./hooks/useDynamicSecureStorageQueries"; 14 | import { log, syncLogger } from "./utils/logger"; 15 | import { 16 | handleStorageRemoval, 17 | handleStorageUpdate, 18 | type StorageInterface, 19 | } from "./utils/storageHandlers"; 20 | import { Dehydrate } from "./hydration"; 21 | import { PlatformOS } from "./platformUtils"; 22 | import { SyncMessage } from "./types"; 23 | import { useMySocket } from "./useMySocket"; 24 | import { useDynamicEnv } from "./hooks/useDynamicEnvQueries"; 25 | 26 | /** 27 | * Query actions that can be performed on a query. 28 | * These actions are used to synchronize query state between devices and the dashboard. 29 | */ 30 | type QueryActions = 31 | // Regular query actions 32 | | "ACTION-REFETCH" // Refetch a query without invalidating it 33 | | "ACTION-INVALIDATE" // Invalidate a query and trigger a refetch 34 | | "ACTION-RESET" // Reset a query to its initial state 35 | | "ACTION-REMOVE" // Remove a query from the cache 36 | | "ACTION-DATA-UPDATE" // Update a query's data manually 37 | // Error handling actions 38 | | "ACTION-TRIGGER-ERROR" // Manually trigger an error state 39 | | "ACTION-RESTORE-ERROR" // Restore from an error state 40 | // Loading state actions 41 | | "ACTION-TRIGGER-LOADING" // Manually trigger a loading state 42 | | "ACTION-RESTORE-LOADING" // Restore from a loading state 43 | // Online status actions 44 | | "ACTION-ONLINE-MANAGER-ONLINE" // Set online manager to online 45 | | "ACTION-ONLINE-MANAGER-OFFLINE" // Set online manager to offline 46 | // Internal action 47 | | "success" // Internal success action 48 | | "ACTION-CLEAR-MUTATION-CACHE" // Clear the mutation cache 49 | | "ACTION-CLEAR-QUERY-CACHE"; // Clear the query cache 50 | 51 | /** 52 | * Message structure for query actions between dashboard and devices 53 | */ 54 | interface QueryActionMessage { 55 | queryHash: string; // Unique hash of the query 56 | queryKey: QueryKey; // Key array used to identify the query 57 | data: unknown; // Data payload (if applicable) 58 | action: QueryActions; // Action to perform 59 | deviceId: string; // Device to target 60 | } 61 | 62 | /** 63 | * Message structure for online manager actions from dashboard to devices 64 | */ 65 | interface OnlineManagerMessage { 66 | action: "ACTION-ONLINE-MANAGER-ONLINE" | "ACTION-ONLINE-MANAGER-OFFLINE"; 67 | targetDeviceId: string; // Device ID to target ('All' || device) 68 | } 69 | 70 | /** 71 | * Determines if a message should be processed by the current device 72 | */ 73 | interface ShouldProcessMessageProps { 74 | targetDeviceId: string; 75 | currentDeviceId: string; 76 | } 77 | function shouldProcessMessage({ 78 | targetDeviceId, 79 | currentDeviceId, 80 | }: ShouldProcessMessageProps): boolean { 81 | return targetDeviceId === currentDeviceId || targetDeviceId === "All"; 82 | } 83 | 84 | /** 85 | * Verifies if the React Query version is compatible with dev tools 86 | */ 87 | function checkVersion(queryClient: QueryClient) { 88 | // Basic version check 89 | const version = ( 90 | queryClient as unknown as { 91 | getDefaultOptions?: () => { queries?: { version?: unknown } }; 92 | } 93 | ).getDefaultOptions?.()?.queries?.version; 94 | if ( 95 | version && 96 | !version.toString().startsWith("4") && 97 | !version.toString().startsWith("5") 98 | ) { 99 | log( 100 | "This version of React Query has not been tested with the dev tools plugin. Some features might not work as expected.", 101 | true, 102 | "warn" 103 | ); 104 | } 105 | } 106 | 107 | /** 108 | * SecureStore static interface (from expo-secure-store) 109 | */ 110 | interface SecureStoreStatic { 111 | getItemAsync: (key: string) => Promise; 112 | setItemAsync?: (key: string, value: string) => Promise; 113 | deleteItemAsync?: (key: string) => Promise; 114 | } 115 | 116 | /** 117 | * Extended MMKV interface that includes set/delete methods 118 | */ 119 | interface MmkvWithSetDelete { 120 | set?: (key: string, value: string | number | boolean) => void; 121 | setString?: (key: string, value: string) => void; 122 | delete?: (key: string) => void; 123 | } 124 | 125 | interface useSyncQueriesExternalProps { 126 | queryClient: QueryClient; 127 | deviceName: string; 128 | /** 129 | * A unique identifier for this device that persists across app restarts. 130 | * This is crucial for proper device tracking, especially if you have multiple devices of the same type. 131 | * If you only have one iOS and one Android device, you can use 'ios' and 'android'. 132 | * For multiple devices of the same type, ensure this ID is unique and persistent. 133 | */ 134 | deviceId: string; 135 | extraDeviceInfo?: Record; // Additional device information as key-value pairs 136 | /** 137 | * Additional environment variables to include beyond the automatically collected EXPO_PUBLIC_ variables. 138 | * The hook automatically collects all EXPO_PUBLIC_ prefixed environment variables. 139 | * Use this parameter to add any additional env vars you want to send to the dashboard. 140 | */ 141 | envVariables?: Record; 142 | socketURL: string; 143 | platform: PlatformOS; // Required platform 144 | /** 145 | * Enable/disable logging for debugging purposes 146 | * @default false 147 | */ 148 | enableLogs?: boolean; 149 | /** 150 | * Whether the app is running on a physical device or an emulator/simulator 151 | * This can affect how the socket URL is constructed, especially on Android 152 | * @default false 153 | */ 154 | isDevice?: boolean; // Whether the app is running on a physical device 155 | 156 | /** 157 | * Storage instances for different storage types 158 | * When provided, these will automatically enable both external sync AND storage monitoring 159 | * 160 | * - mmkvStorage: MMKV storage instance (enables ['#storage', 'mmkv', 'key'] queries + monitoring) 161 | * - asyncStorage: AsyncStorage instance (enables ['#storage', 'async', 'key'] queries + monitoring) 162 | * - secureStorage: SecureStore instance (enables ['#storage', 'secure', 'key'] queries + monitoring) 163 | * - secureStorageKeys: Array of SecureStore keys to monitor (required when using secureStorage) 164 | * - secureStoragePollInterval: Polling interval for SecureStore monitoring (default: 1000ms) 165 | */ 166 | storage?: StorageInterface; // Legacy prop for backward compatibility 167 | mmkvStorage?: StorageInterface | MmkvStorage; // MMKV storage instance (will be auto-adapted) 168 | asyncStorage?: StorageInterface | AsyncStorageStatic; // AsyncStorage instance (will be auto-adapted) 169 | secureStorage?: StorageInterface | SecureStoreStatic; // SecureStore instance (will be auto-adapted) 170 | secureStorageKeys?: string[]; // Required when using secureStorage - keys to monitor 171 | secureStoragePollInterval?: number; // Optional polling interval for SecureStore (default: 1000ms) 172 | } 173 | 174 | /** 175 | * Helper function to detect storage type and create an adapter if needed 176 | */ 177 | function createStorageAdapter( 178 | storage: 179 | | StorageInterface 180 | | AsyncStorageStatic 181 | | SecureStoreStatic 182 | | MmkvStorage 183 | ): StorageInterface { 184 | // Note: These debug logs are intentionally minimal to reduce noise 185 | // They can be enabled for deep debugging if needed 186 | 187 | // Check if it's already a StorageInterface-compatible storage 188 | if ("set" in storage && "delete" in storage) { 189 | return storage as StorageInterface; 190 | } 191 | 192 | // Check if it's MMKV by looking for MMKV-specific methods 193 | if ( 194 | "getString" in storage && 195 | "getAllKeys" in storage && 196 | "addOnValueChangedListener" in storage 197 | ) { 198 | // MMKV has different method names, create an adapter 199 | const mmkvStorage = storage as MmkvStorage; 200 | return { 201 | set: (key: string, value: string | number | boolean) => { 202 | // MMKV doesn't have a generic set method, we need to use the specific type methods 203 | // For now, we'll convert everything to string and use setString 204 | // Note: This assumes the MMKV instance has setString method 205 | const mmkvWithSet = mmkvStorage as MmkvStorage & MmkvWithSetDelete; 206 | if (mmkvWithSet.set) { 207 | mmkvWithSet.set(key, value); 208 | } else if (mmkvWithSet.setString) { 209 | const stringValue = 210 | typeof value === "string" ? value : JSON.stringify(value); 211 | mmkvWithSet.setString(key, stringValue); 212 | } else { 213 | console.warn("⚠️ MMKV storage does not have set or setString method"); 214 | } 215 | }, 216 | delete: (key: string) => { 217 | const mmkvWithDelete = mmkvStorage as MmkvStorage & MmkvWithSetDelete; 218 | if (mmkvWithDelete.delete) { 219 | mmkvWithDelete.delete(key); 220 | } else { 221 | console.warn("⚠️ MMKV storage does not have delete method"); 222 | } 223 | }, 224 | }; 225 | } 226 | 227 | // Check if it's AsyncStorage by looking for setItem/removeItem methods 228 | if ("setItem" in storage && "removeItem" in storage) { 229 | // This is AsyncStorage, create an adapter 230 | return { 231 | set: (key: string, value: string | number | boolean) => { 232 | const stringValue = 233 | typeof value === "string" ? value : JSON.stringify(value); 234 | return storage.setItem(key, stringValue); 235 | }, 236 | delete: (key: string) => { 237 | return storage.removeItem(key); 238 | }, 239 | }; 240 | } 241 | 242 | // Check if it's SecureStore by looking for setItemAsync/deleteItemAsync methods 243 | if ("setItemAsync" in storage && "deleteItemAsync" in storage) { 244 | // This is SecureStore, create an adapter 245 | const secureStore = storage as SecureStoreStatic; 246 | return { 247 | set: (key: string, value: string | number | boolean) => { 248 | const stringValue = 249 | typeof value === "string" ? value : JSON.stringify(value); 250 | if (secureStore.setItemAsync) { 251 | return secureStore.setItemAsync(key, stringValue); 252 | } 253 | throw new Error("SecureStore setItemAsync method not available"); 254 | }, 255 | delete: (key: string) => { 256 | if (secureStore.deleteItemAsync) { 257 | return secureStore.deleteItemAsync(key); 258 | } 259 | throw new Error("SecureStore deleteItemAsync method not available"); 260 | }, 261 | }; 262 | } 263 | 264 | // Fallback - assume it's already compatible 265 | return storage as unknown as StorageInterface; 266 | } 267 | 268 | /** 269 | * Hook used by mobile devices to sync query state with the external dashboard 270 | * 271 | * Handles: 272 | * - Connection to the socket server 273 | * - Responding to dashboard requests 274 | * - Processing query actions from the dashboard 275 | * - Sending query state updates to the dashboard 276 | * - Automatically collecting all EXPO_PUBLIC_ environment variables 277 | * - Merging additional user-provided environment variables 278 | * - Supporting multiple storage types (MMKV, AsyncStorage, SecureStore) 279 | * - Integrated storage monitoring (automatically monitors storage when instances are provided) 280 | * 281 | * @example 282 | * // Basic usage with MMKV only (legacy) 283 | * useSyncQueriesExternal({ 284 | * queryClient, 285 | * socketURL: 'http://localhost:42831', 286 | * deviceName: 'iOS Simulator', 287 | * platform: 'ios', 288 | * deviceId: 'ios-sim-1', 289 | * storage: mmkvStorage, // Your MMKV instance 290 | * }); 291 | * 292 | * @example 293 | * // Advanced usage with MMKV, AsyncStorage, and SecureStore 294 | * // This automatically enables both external sync AND storage monitoring 295 | * import AsyncStorage from '@react-native-async-storage/async-storage'; 296 | * import * as SecureStore from 'expo-secure-store'; 297 | * import { storage as mmkvStorage } from '~/lib/storage/mmkv'; 298 | * 299 | * useSyncQueriesExternal({ 300 | * queryClient, 301 | * socketURL: 'http://localhost:42831', 302 | * deviceName: 'iOS Simulator', 303 | * platform: 'ios', 304 | * deviceId: 'ios-sim-1', 305 | * mmkvStorage: mmkvStorage, // Enables ['#storage', 'mmkv', 'key'] queries + MMKV monitoring 306 | * asyncStorage: AsyncStorage, // Enables ['#storage', 'async', 'key'] queries + AsyncStorage monitoring 307 | * secureStorage: SecureStore, // Enables ['#storage', 'secure', 'key'] queries + SecureStore monitoring 308 | * secureStorageKeys: ['sessionToken', 'auth.session', 'auth.email'], // Required for SecureStore monitoring 309 | * enableLogs: true, 310 | * }); 311 | */ 312 | export function useSyncQueriesExternal({ 313 | queryClient, 314 | deviceName, 315 | socketURL, 316 | extraDeviceInfo, 317 | envVariables, 318 | platform, 319 | deviceId, 320 | enableLogs = false, 321 | isDevice = false, 322 | storage, 323 | mmkvStorage, 324 | asyncStorage, 325 | secureStorage, 326 | secureStorageKeys, 327 | secureStoragePollInterval, 328 | }: useSyncQueriesExternalProps) { 329 | // ========================================================== 330 | // Validate deviceId 331 | // ========================================================== 332 | if (!deviceId?.trim()) { 333 | throw new Error( 334 | `[${deviceName}] deviceId is required and must not be empty. This ID must persist across app restarts, especially if you have multiple devices of the same type. If you only have one iOS and one Android device, you can use 'ios' and 'android'.` 335 | ); 336 | } 337 | 338 | // ========================================================== 339 | // Auto-collect environment variables 340 | // ========================================================== 341 | const envResults = useDynamicEnv(); 342 | 343 | // Convert env results to a simple key-value object 344 | const autoCollectedEnvVars = useMemo(() => { 345 | const envVars: Record = {}; 346 | 347 | envResults.forEach(({ key, data }) => { 348 | // Include all available env vars 349 | if (data !== undefined && data !== null) { 350 | // Convert data to string for transmission 351 | envVars[key] = typeof data === "string" ? data : JSON.stringify(data); 352 | } 353 | }); 354 | 355 | return envVars; 356 | }, [envResults]); 357 | 358 | // Merge auto-collected env vars with user-provided ones (user-provided take precedence) 359 | const mergedEnvVariables = useMemo(() => { 360 | const merged = { 361 | ...autoCollectedEnvVars, 362 | ...(envVariables || {}), 363 | }; 364 | 365 | return merged; 366 | }, [autoCollectedEnvVars, envVariables]); 367 | 368 | // ========================================================== 369 | // Persistent device ID - used to identify this device 370 | // across app restarts 371 | // ========================================================== 372 | const logPrefix = `[${deviceName}]`; 373 | 374 | // ========================================================== 375 | // Integrated Storage Monitoring 376 | // Automatically enable storage monitoring when storage instances are provided 377 | // ========================================================== 378 | 379 | // MMKV monitoring - only if mmkvStorage is provided and has the required methods 380 | const mmkvQueries = useDynamicMmkvQueries({ 381 | queryClient, 382 | storage: 383 | mmkvStorage && "getAllKeys" in mmkvStorage 384 | ? (mmkvStorage as MmkvStorage) 385 | : { 386 | getAllKeys: () => [], 387 | getString: () => undefined, 388 | getNumber: () => undefined, 389 | getBoolean: () => undefined, 390 | addOnValueChangedListener: () => ({ remove: () => {} }), 391 | }, 392 | }); 393 | 394 | // AsyncStorage monitoring - only if asyncStorage is provided 395 | const asyncStorageQueries = useDynamicAsyncStorageQueries({ 396 | queryClient, 397 | asyncStorage: 398 | asyncStorage && "getItem" in asyncStorage && "getAllKeys" in asyncStorage 399 | ? (asyncStorage as AsyncStorageStatic) 400 | : undefined, 401 | enabled: !!asyncStorage, // Only enable when asyncStorage is provided 402 | }); 403 | 404 | // SecureStorage monitoring - only if secureStorage and secureStorageKeys are provided 405 | const secureStorageQueries = useDynamicSecureStorageQueries({ 406 | queryClient, 407 | secureStorage: 408 | secureStorage && "getItemAsync" in secureStorage 409 | ? (secureStorage as SecureStoreStatic) 410 | : undefined, 411 | knownKeys: secureStorageKeys || [], 412 | pollInterval: secureStoragePollInterval || 1000, 413 | }); 414 | 415 | // Use a ref to track previous connection state to avoid duplicate logs 416 | const prevConnectedRef = useRef(false); 417 | const prevEnvVarsRef = useRef>({}); 418 | const storageLoggingDoneRef = useRef(false); 419 | 420 | // Log storage monitoring status once 421 | useEffect(() => { 422 | if (enableLogs && !storageLoggingDoneRef.current) { 423 | // Removed redundant storage monitoring status log for cleaner output 424 | storageLoggingDoneRef.current = true; 425 | } 426 | }, [ 427 | mmkvStorage, 428 | asyncStorage, 429 | secureStorage, 430 | secureStorageKeys, 431 | enableLogs, 432 | deviceName, 433 | ]); 434 | 435 | // ========================================================== 436 | // Socket connection - Handles connection to the socket server and 437 | // event listeners for the socket server 438 | // Connect immediately since env vars are available synchronously 439 | // ========================================================== 440 | 441 | const { connect, disconnect, isConnected, socket } = useMySocket({ 442 | deviceName, 443 | socketURL, 444 | persistentDeviceId: deviceId, 445 | extraDeviceInfo, 446 | envVariables: mergedEnvVariables, 447 | platform, 448 | enableLogs, 449 | isDevice, 450 | }); 451 | 452 | useEffect(() => { 453 | checkVersion(queryClient); 454 | 455 | // Only log connection state changes to reduce noise 456 | if (prevConnectedRef.current !== isConnected) { 457 | if (!isConnected) { 458 | log(`${logPrefix} Not connected to external dashboard`, enableLogs); 459 | } else { 460 | log(`${deviceName} Connected to external dashboard`, enableLogs); 461 | } 462 | prevConnectedRef.current = isConnected; 463 | } 464 | 465 | // Send updated env vars if they changed after connection (for failsafe scenarios) 466 | if (isConnected && socket && mergedEnvVariables) { 467 | const currentEnvVarsKey = JSON.stringify(mergedEnvVariables); 468 | const prevEnvVarsKey = JSON.stringify(prevEnvVarsRef.current); 469 | 470 | if ( 471 | currentEnvVarsKey !== prevEnvVarsKey && 472 | Object.keys(mergedEnvVariables).length > 473 | Object.keys(prevEnvVarsRef.current).length 474 | ) { 475 | log( 476 | `${deviceName} Sending updated environment variables to dashboard (post-failsafe)`, 477 | enableLogs 478 | ); 479 | socket.emit("env-vars-update", { 480 | deviceId, 481 | envVariables: mergedEnvVariables, 482 | }); 483 | prevEnvVarsRef.current = { ...mergedEnvVariables }; 484 | } 485 | } 486 | 487 | // Don't proceed with setting up event handlers if not connected 488 | if (!isConnected || !socket) { 489 | return; 490 | } 491 | 492 | // ========================================================== 493 | // Event Handlers 494 | // ========================================================== 495 | 496 | // ========================================================== 497 | // Handle initial state requests from dashboard 498 | // ========================================================== 499 | const initialStateSubscription = socket.on("request-initial-state", () => { 500 | if (!deviceId) { 501 | log(`${logPrefix} No persistent device ID found`, enableLogs, "warn"); 502 | return; 503 | } 504 | log(`${logPrefix} Dashboard is requesting initial state`, enableLogs); 505 | const dehydratedState = Dehydrate(queryClient as unknown as QueryClient); 506 | const syncMessage: SyncMessage = { 507 | type: "dehydrated-state", 508 | state: dehydratedState, 509 | isOnlineManagerOnline: onlineManager.isOnline(), 510 | persistentDeviceId: deviceId, 511 | }; 512 | socket.emit("query-sync", syncMessage); 513 | log( 514 | `[${deviceName}] Sent initial state to dashboard (${dehydratedState.queries.length} queries)`, 515 | enableLogs 516 | ); 517 | }); 518 | 519 | // ========================================================== 520 | // Online manager handler - Handle device internet connection state changes 521 | // ========================================================== 522 | const onlineManagerSubscription = socket.on( 523 | "online-manager", 524 | (message: OnlineManagerMessage) => { 525 | const { action, targetDeviceId } = message; 526 | if (!deviceId) { 527 | log(`${logPrefix} No persistent device ID found`, enableLogs, "warn"); 528 | return; 529 | } 530 | // Only process if this message targets the current device 531 | if ( 532 | !shouldProcessMessage({ 533 | targetDeviceId: targetDeviceId, 534 | currentDeviceId: deviceId, 535 | }) 536 | ) { 537 | return; 538 | } 539 | 540 | // Start a sync operation for this online manager action 541 | const operationId = syncLogger.startOperation( 542 | "query-action", 543 | { 544 | deviceName, 545 | deviceId, 546 | platform, 547 | }, 548 | enableLogs 549 | ); 550 | 551 | switch (action) { 552 | case "ACTION-ONLINE-MANAGER-ONLINE": { 553 | onlineManager.setOnline(true); 554 | syncLogger.logQueryAction(operationId, action, "online-manager"); 555 | break; 556 | } 557 | case "ACTION-ONLINE-MANAGER-OFFLINE": { 558 | onlineManager.setOnline(false); 559 | syncLogger.logQueryAction(operationId, action, "online-manager"); 560 | break; 561 | } 562 | } 563 | 564 | // Complete the operation 565 | syncLogger.completeOperation(operationId); 566 | } 567 | ); 568 | 569 | // ========================================================== 570 | // Query Actions handler - Process actions from the dashboard 571 | // ========================================================== 572 | const queryActionSubscription = socket.on( 573 | "query-action", 574 | (message: QueryActionMessage) => { 575 | const { queryHash, queryKey, data, action, deviceId } = message; 576 | if (!deviceId) { 577 | log( 578 | `[${deviceName}] No persistent device ID found`, 579 | enableLogs, 580 | "warn" 581 | ); 582 | return; 583 | } 584 | // Skip if not targeted at this device 585 | if ( 586 | !shouldProcessMessage({ 587 | targetDeviceId: deviceId, 588 | currentDeviceId: deviceId, 589 | }) 590 | ) { 591 | return; 592 | } 593 | 594 | // Start a sync operation for this query action 595 | const operationId = syncLogger.startOperation( 596 | "query-action", 597 | { 598 | deviceName, 599 | deviceId, 600 | platform, 601 | }, 602 | enableLogs 603 | ); 604 | 605 | // If action is clear cache do the action here before moving on 606 | if (action === "ACTION-CLEAR-MUTATION-CACHE") { 607 | queryClient.getMutationCache().clear(); 608 | syncLogger.logQueryAction(operationId, action, "mutation-cache"); 609 | syncLogger.completeOperation(operationId); 610 | return; 611 | } 612 | if (action === "ACTION-CLEAR-QUERY-CACHE") { 613 | queryClient.getQueryCache().clear(); 614 | syncLogger.logQueryAction(operationId, action, "query-cache"); 615 | syncLogger.completeOperation(operationId); 616 | return; 617 | } 618 | 619 | const activeQuery = queryClient.getQueryCache().get(queryHash); 620 | if (!activeQuery) { 621 | syncLogger.logError( 622 | operationId, 623 | "Query Not Found", 624 | `Query with hash ${queryHash} not found` 625 | ); 626 | // Removed redundant log for cleaner output 627 | syncLogger.completeOperation(operationId, false); 628 | return; 629 | } 630 | 631 | try { 632 | switch (action) { 633 | case "ACTION-DATA-UPDATE": { 634 | // Check if this is a storage query 635 | if ( 636 | Array.isArray(queryKey) && 637 | queryKey.length === 3 && 638 | queryKey[0] === "#storage" 639 | ) { 640 | const storageType = queryKey[1] as string; 641 | const storageKey = queryKey[2] as string; 642 | 643 | // Determine which storage instance to use based on storage type 644 | let storageInstance: StorageInterface | undefined; 645 | switch (storageType.toLowerCase()) { 646 | case "mmkv": 647 | const rawMmkvStorage = mmkvStorage || storage; 648 | storageInstance = rawMmkvStorage 649 | ? createStorageAdapter(rawMmkvStorage) 650 | : undefined; 651 | break; 652 | case "asyncstorage": 653 | case "async-storage": 654 | case "async": 655 | const rawAsyncStorage = asyncStorage || storage; 656 | storageInstance = rawAsyncStorage 657 | ? createStorageAdapter(rawAsyncStorage) 658 | : undefined; 659 | break; 660 | case "securestorage": 661 | case "secure-storage": 662 | case "secure": 663 | const rawSecureStorage = secureStorage || storage; 664 | storageInstance = rawSecureStorage 665 | ? createStorageAdapter(rawSecureStorage) 666 | : undefined; 667 | break; 668 | default: 669 | storageInstance = storage; 670 | break; 671 | } 672 | 673 | // Log the storage update with current and new values 674 | const currentValue = queryClient.getQueryData(queryKey); 675 | const storageTypeForLogger = 676 | storageType.toLowerCase() === "mmkv" 677 | ? "mmkv" 678 | : storageType.toLowerCase().includes("async") 679 | ? "asyncStorage" 680 | : "secureStore"; 681 | syncLogger.logStorageUpdate( 682 | operationId, 683 | storageTypeForLogger, 684 | storageKey, 685 | currentValue, 686 | data 687 | ); 688 | 689 | // This is a storage query, handle it with the storage handler 690 | const wasStorageHandled = handleStorageUpdate( 691 | queryKey, 692 | data, 693 | queryClient, 694 | storageInstance, 695 | enableLogs, 696 | deviceName 697 | ); 698 | 699 | // If storage handler couldn't handle it, fall back to regular update 700 | if (!wasStorageHandled) { 701 | queryClient.setQueryData(queryKey, data, { 702 | updatedAt: Date.now(), 703 | }); 704 | } 705 | } else { 706 | // Not a storage query, handle as regular query data update 707 | queryClient.setQueryData(queryKey, data, { 708 | updatedAt: Date.now(), 709 | }); 710 | } 711 | 712 | syncLogger.logQueryAction(operationId, action, queryHash); 713 | break; 714 | } 715 | 716 | case "ACTION-TRIGGER-ERROR": { 717 | // Removed redundant log for cleaner output 718 | const error = new Error("Unknown error from devtools"); 719 | 720 | const __previousQueryOptions = activeQuery.options; 721 | activeQuery.setState({ 722 | status: "error", 723 | error, 724 | fetchMeta: { 725 | ...activeQuery.state.fetchMeta, 726 | // @ts-expect-error This does exist 727 | __previousQueryOptions, 728 | }, 729 | }); 730 | syncLogger.logQueryAction(operationId, action, queryHash); 731 | break; 732 | } 733 | case "ACTION-RESTORE-ERROR": { 734 | // Removed redundant log for cleaner output 735 | queryClient.resetQueries(activeQuery); 736 | syncLogger.logQueryAction(operationId, action, queryHash); 737 | break; 738 | } 739 | case "ACTION-TRIGGER-LOADING": { 740 | if (!activeQuery) return; 741 | // Removed redundant log for cleaner output 742 | const __previousQueryOptions = activeQuery.options; 743 | // Trigger a fetch in order to trigger suspense as well. 744 | activeQuery.fetch({ 745 | ...__previousQueryOptions, 746 | queryFn: () => { 747 | return new Promise(() => { 748 | // Never resolve - simulates perpetual loading 749 | }); 750 | }, 751 | gcTime: -1, 752 | }); 753 | activeQuery.setState({ 754 | data: undefined, 755 | status: "pending", 756 | fetchMeta: { 757 | ...activeQuery.state.fetchMeta, 758 | // @ts-expect-error This does exist 759 | __previousQueryOptions, 760 | }, 761 | }); 762 | syncLogger.logQueryAction(operationId, action, queryHash); 763 | break; 764 | } 765 | case "ACTION-RESTORE-LOADING": { 766 | // Removed redundant log for cleaner output 767 | const previousState = activeQuery.state; 768 | const previousOptions = activeQuery.state.fetchMeta 769 | ? ( 770 | activeQuery.state.fetchMeta as unknown as { 771 | __previousQueryOptions: unknown; 772 | } 773 | ).__previousQueryOptions 774 | : null; 775 | 776 | activeQuery.cancel({ silent: true }); 777 | activeQuery.setState({ 778 | ...previousState, 779 | fetchStatus: "idle", 780 | fetchMeta: null, 781 | }); 782 | 783 | if (previousOptions) { 784 | activeQuery.fetch(previousOptions); 785 | } 786 | syncLogger.logQueryAction(operationId, action, queryHash); 787 | break; 788 | } 789 | case "ACTION-RESET": { 790 | // Removed redundant log for cleaner output 791 | queryClient.resetQueries(activeQuery); 792 | syncLogger.logQueryAction(operationId, action, queryHash); 793 | break; 794 | } 795 | case "ACTION-REMOVE": { 796 | // Check if this is a storage query 797 | if ( 798 | Array.isArray(queryKey) && 799 | queryKey.length === 3 && 800 | queryKey[0] === "#storage" 801 | ) { 802 | const storageType = queryKey[1] as string; 803 | const storageKey = queryKey[2] as string; 804 | 805 | // Determine which storage instance to use based on storage type 806 | let storageInstance: StorageInterface | undefined; 807 | switch (storageType.toLowerCase()) { 808 | case "mmkv": 809 | const rawMmkvStorage = mmkvStorage || storage; 810 | storageInstance = rawMmkvStorage 811 | ? createStorageAdapter(rawMmkvStorage) 812 | : undefined; 813 | break; 814 | case "asyncstorage": 815 | case "async-storage": 816 | case "async": 817 | const rawAsyncStorage = asyncStorage || storage; 818 | storageInstance = rawAsyncStorage 819 | ? createStorageAdapter(rawAsyncStorage) 820 | : undefined; 821 | break; 822 | case "securestorage": 823 | case "secure-storage": 824 | case "secure": 825 | const rawSecureStorage = secureStorage || storage; 826 | storageInstance = rawSecureStorage 827 | ? createStorageAdapter(rawSecureStorage) 828 | : undefined; 829 | break; 830 | default: 831 | storageInstance = storage; 832 | break; 833 | } 834 | 835 | // Log the storage removal 836 | const currentValue = queryClient.getQueryData(queryKey); 837 | const storageTypeForLogger = 838 | storageType.toLowerCase() === "mmkv" 839 | ? "mmkv" 840 | : storageType.toLowerCase().includes("async") 841 | ? "asyncStorage" 842 | : "secureStore"; 843 | syncLogger.logStorageUpdate( 844 | operationId, 845 | storageTypeForLogger, 846 | storageKey, 847 | currentValue, 848 | null 849 | ); 850 | 851 | // This is a storage query, handle it with the storage removal handler 852 | const wasStorageHandled = handleStorageRemoval( 853 | queryKey, 854 | queryClient, 855 | storageInstance, 856 | enableLogs, 857 | deviceName 858 | ); 859 | 860 | // If storage handler couldn't handle it, fall back to regular removal 861 | if (!wasStorageHandled) { 862 | queryClient.removeQueries(activeQuery); 863 | } 864 | } else { 865 | // Not a storage query, handle as regular query removal 866 | queryClient.removeQueries(activeQuery); 867 | } 868 | 869 | syncLogger.logQueryAction(operationId, action, queryHash); 870 | break; 871 | } 872 | case "ACTION-REFETCH": { 873 | // Removed redundant log for cleaner output 874 | const promise = activeQuery.fetch(); 875 | promise.catch((error) => { 876 | // Log fetch errors but don't propagate them 877 | syncLogger.logError( 878 | operationId, 879 | "Refetch Error", 880 | `Refetch failed for ${queryHash}`, 881 | error 882 | ); 883 | log( 884 | `[${deviceName}] Refetch error for ${queryHash}:`, 885 | enableLogs, 886 | "error" 887 | ); 888 | }); 889 | syncLogger.logQueryAction(operationId, action, queryHash); 890 | break; 891 | } 892 | case "ACTION-INVALIDATE": { 893 | // Removed redundant log for cleaner output 894 | queryClient.invalidateQueries(activeQuery); 895 | syncLogger.logQueryAction(operationId, action, queryHash); 896 | break; 897 | } 898 | case "ACTION-ONLINE-MANAGER-ONLINE": { 899 | // Removed redundant log for cleaner output 900 | onlineManager.setOnline(true); 901 | syncLogger.logQueryAction(operationId, action, "online-manager"); 902 | break; 903 | } 904 | case "ACTION-ONLINE-MANAGER-OFFLINE": { 905 | // Removed redundant log for cleaner output 906 | onlineManager.setOnline(false); 907 | syncLogger.logQueryAction(operationId, action, "online-manager"); 908 | break; 909 | } 910 | } 911 | 912 | // Complete the operation successfully 913 | syncLogger.completeOperation(operationId); 914 | } catch (error) { 915 | // Complete the operation with error 916 | syncLogger.logError( 917 | operationId, 918 | "Action Error", 919 | `Failed to execute ${action}`, 920 | error as Error 921 | ); 922 | syncLogger.completeOperation(operationId, false); 923 | } 924 | } 925 | ); 926 | 927 | // ========================================================== 928 | // Subscribe to query changes and sync to dashboard 929 | // ========================================================== 930 | const unsubscribe = queryClient.getQueryCache().subscribe(() => { 931 | if (!deviceId) { 932 | log(`${logPrefix} No persistent device ID found`, enableLogs, "warn"); 933 | return; 934 | } 935 | // Dehydrate the current state 936 | const dehydratedState = Dehydrate(queryClient as unknown as QueryClient); 937 | 938 | // Create sync message 939 | const syncMessage: SyncMessage = { 940 | type: "dehydrated-state", 941 | state: dehydratedState, 942 | isOnlineManagerOnline: onlineManager.isOnline(), 943 | persistentDeviceId: deviceId, 944 | }; 945 | 946 | // Send message to dashboard 947 | socket.emit("query-sync", syncMessage); 948 | }); 949 | 950 | // ========================================================== 951 | // Cleanup function to unsubscribe from all events 952 | // ========================================================== 953 | return () => { 954 | // Removed repetitive cleanup logging for cleaner output 955 | queryActionSubscription?.off(); 956 | initialStateSubscription?.off(); 957 | onlineManagerSubscription?.off(); 958 | unsubscribe(); 959 | }; 960 | }, [ 961 | queryClient, 962 | socket, 963 | deviceName, 964 | isConnected, 965 | deviceId, 966 | enableLogs, 967 | logPrefix, 968 | mergedEnvVariables, 969 | storage, 970 | mmkvStorage, 971 | asyncStorage, 972 | secureStorage, 973 | secureStorageKeys, 974 | secureStoragePollInterval, 975 | platform, 976 | ]); 977 | 978 | return { connect, disconnect, isConnected, socket }; 979 | } 980 | --------------------------------------------------------------------------------