react-query-external-sync
Version:
A tool for syncing React Query state to an external Dev Tools
654 lines • 32.9 kB
JavaScript
import { useEffect, useMemo, useRef } from "react";
import { onlineManager } from "@tanstack/react-query";
import { useDynamicAsyncStorageQueries, } from "./hooks/useDynamicAsyncStorageQueries";
import { useDynamicMmkvQueries, } from "./hooks/useDynamicMmkvQueries";
import { useDynamicSecureStorageQueries } from "./hooks/useDynamicSecureStorageQueries";
import { log, syncLogger } from "./utils/logger";
import { handleStorageRemoval, handleStorageUpdate, } from "./utils/storageHandlers";
import { Dehydrate } from "./hydration";
import { useMySocket } from "./useMySocket";
import { useDynamicEnv } from "./hooks/useDynamicEnvQueries";
function shouldProcessMessage({ targetDeviceId, currentDeviceId, }) {
return targetDeviceId === currentDeviceId || targetDeviceId === "All";
}
/**
* Verifies if the React Query version is compatible with dev tools
*/
function checkVersion(queryClient) {
var _a, _b, _c, _d;
// Basic version check
const version = (_d = (_c = (_b = (_a = queryClient).getDefaultOptions) === null || _b === void 0 ? void 0 : _b.call(_a)) === null || _c === void 0 ? void 0 : _c.queries) === null || _d === void 0 ? void 0 : _d.version;
if (version &&
!version.toString().startsWith("4") &&
!version.toString().startsWith("5")) {
log("This version of React Query has not been tested with the dev tools plugin. Some features might not work as expected.", true, "warn");
}
}
/**
* Helper function to detect storage type and create an adapter if needed
*/
function createStorageAdapter(storage) {
// Note: These debug logs are intentionally minimal to reduce noise
// They can be enabled for deep debugging if needed
// Check if it's already a StorageInterface-compatible storage
if ("set" in storage && "delete" in storage) {
return storage;
}
// Check if it's MMKV by looking for MMKV-specific methods
if ("getString" in storage &&
"getAllKeys" in storage &&
"addOnValueChangedListener" in storage) {
// MMKV has different method names, create an adapter
const mmkvStorage = storage;
return {
set: (key, value) => {
// MMKV doesn't have a generic set method, we need to use the specific type methods
// For now, we'll convert everything to string and use setString
// Note: This assumes the MMKV instance has setString method
const mmkvWithSet = mmkvStorage;
if (mmkvWithSet.set) {
mmkvWithSet.set(key, value);
}
else if (mmkvWithSet.setString) {
const stringValue = typeof value === "string" ? value : JSON.stringify(value);
mmkvWithSet.setString(key, stringValue);
}
else {
console.warn("⚠️ MMKV storage does not have set or setString method");
}
},
delete: (key) => {
const mmkvWithDelete = mmkvStorage;
if (mmkvWithDelete.delete) {
mmkvWithDelete.delete(key);
}
else {
console.warn("⚠️ MMKV storage does not have delete method");
}
},
};
}
// Check if it's AsyncStorage by looking for setItem/removeItem methods
if ("setItem" in storage && "removeItem" in storage) {
// This is AsyncStorage, create an adapter
return {
set: (key, value) => {
const stringValue = typeof value === "string" ? value : JSON.stringify(value);
return storage.setItem(key, stringValue);
},
delete: (key) => {
return storage.removeItem(key);
},
};
}
// Check if it's SecureStore by looking for setItemAsync/deleteItemAsync methods
if ("setItemAsync" in storage && "deleteItemAsync" in storage) {
// This is SecureStore, create an adapter
const secureStore = storage;
return {
set: (key, value) => {
const stringValue = typeof value === "string" ? value : JSON.stringify(value);
if (secureStore.setItemAsync) {
return secureStore.setItemAsync(key, stringValue);
}
throw new Error("SecureStore setItemAsync method not available");
},
delete: (key) => {
if (secureStore.deleteItemAsync) {
return secureStore.deleteItemAsync(key);
}
throw new Error("SecureStore deleteItemAsync method not available");
},
};
}
// Fallback - assume it's already compatible
return storage;
}
/**
* Hook used by mobile devices to sync query state with the external dashboard
*
* Handles:
* - Connection to the socket server
* - Responding to dashboard requests
* - Processing query actions from the dashboard
* - Sending query state updates to the dashboard
* - Automatically collecting all EXPO_PUBLIC_ environment variables
* - Merging additional user-provided environment variables
* - Supporting multiple storage types (MMKV, AsyncStorage, SecureStore)
* - Integrated storage monitoring (automatically monitors storage when instances are provided)
*
* @example
* // Basic usage with MMKV only (legacy)
* useSyncQueriesExternal({
* queryClient,
* socketURL: 'http://localhost:42831',
* deviceName: 'iOS Simulator',
* platform: 'ios',
* deviceId: 'ios-sim-1',
* storage: mmkvStorage, // Your MMKV instance
* });
*
* @example
* // Advanced usage with MMKV, AsyncStorage, and SecureStore
* // This automatically enables both external sync AND storage monitoring
* import AsyncStorage from '@react-native-async-storage/async-storage';
* import * as SecureStore from 'expo-secure-store';
* import { storage as mmkvStorage } from '~/lib/storage/mmkv';
*
* useSyncQueriesExternal({
* queryClient,
* socketURL: 'http://localhost:42831',
* deviceName: 'iOS Simulator',
* platform: 'ios',
* deviceId: 'ios-sim-1',
* mmkvStorage: mmkvStorage, // Enables ['#storage', 'mmkv', 'key'] queries + MMKV monitoring
* asyncStorage: AsyncStorage, // Enables ['#storage', 'async', 'key'] queries + AsyncStorage monitoring
* secureStorage: SecureStore, // Enables ['#storage', 'secure', 'key'] queries + SecureStore monitoring
* secureStorageKeys: ['sessionToken', 'auth.session', 'auth.email'], // Required for SecureStore monitoring
* enableLogs: true,
* });
*/
export function useSyncQueriesExternal({ queryClient, deviceName, socketURL, extraDeviceInfo, envVariables, platform, deviceId, enableLogs = false, isDevice = false, storage, mmkvStorage, asyncStorage, secureStorage, secureStorageKeys, secureStoragePollInterval, }) {
// ==========================================================
// Validate deviceId
// ==========================================================
if (!(deviceId === null || deviceId === void 0 ? void 0 : deviceId.trim())) {
throw new Error(`[${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'.`);
}
// ==========================================================
// Auto-collect environment variables
// ==========================================================
const envResults = useDynamicEnv();
// Convert env results to a simple key-value object
const autoCollectedEnvVars = useMemo(() => {
const envVars = {};
envResults.forEach(({ key, data }) => {
// Include all available env vars
if (data !== undefined && data !== null) {
// Convert data to string for transmission
envVars[key] = typeof data === "string" ? data : JSON.stringify(data);
}
});
return envVars;
}, [envResults]);
// Merge auto-collected env vars with user-provided ones (user-provided take precedence)
const mergedEnvVariables = useMemo(() => {
const merged = Object.assign(Object.assign({}, autoCollectedEnvVars), (envVariables || {}));
return merged;
}, [autoCollectedEnvVars, envVariables]);
// ==========================================================
// Persistent device ID - used to identify this device
// across app restarts
// ==========================================================
const logPrefix = `[${deviceName}]`;
// ==========================================================
// Integrated Storage Monitoring
// Automatically enable storage monitoring when storage instances are provided
// ==========================================================
// MMKV monitoring - only if mmkvStorage is provided and has the required methods
const mmkvQueries = useDynamicMmkvQueries({
queryClient,
storage: mmkvStorage && "getAllKeys" in mmkvStorage
? mmkvStorage
: {
getAllKeys: () => [],
getString: () => undefined,
getNumber: () => undefined,
getBoolean: () => undefined,
addOnValueChangedListener: () => ({ remove: () => { } }),
},
});
// AsyncStorage monitoring - only if asyncStorage is provided
const asyncStorageQueries = useDynamicAsyncStorageQueries({
queryClient,
asyncStorage: asyncStorage && "getItem" in asyncStorage && "getAllKeys" in asyncStorage
? asyncStorage
: undefined,
enabled: !!asyncStorage, // Only enable when asyncStorage is provided
});
// SecureStorage monitoring - only if secureStorage and secureStorageKeys are provided
const secureStorageQueries = useDynamicSecureStorageQueries({
queryClient,
secureStorage: secureStorage && "getItemAsync" in secureStorage
? secureStorage
: undefined,
knownKeys: secureStorageKeys || [],
pollInterval: secureStoragePollInterval || 1000,
});
// Use a ref to track previous connection state to avoid duplicate logs
const prevConnectedRef = useRef(false);
const prevEnvVarsRef = useRef({});
const storageLoggingDoneRef = useRef(false);
// Log storage monitoring status once
useEffect(() => {
if (enableLogs && !storageLoggingDoneRef.current) {
// Removed redundant storage monitoring status log for cleaner output
storageLoggingDoneRef.current = true;
}
}, [
mmkvStorage,
asyncStorage,
secureStorage,
secureStorageKeys,
enableLogs,
deviceName,
]);
// ==========================================================
// Socket connection - Handles connection to the socket server and
// event listeners for the socket server
// Connect immediately since env vars are available synchronously
// ==========================================================
const { connect, disconnect, isConnected, socket } = useMySocket({
deviceName,
socketURL,
persistentDeviceId: deviceId,
extraDeviceInfo,
envVariables: mergedEnvVariables,
platform,
enableLogs,
isDevice,
});
useEffect(() => {
checkVersion(queryClient);
// Only log connection state changes to reduce noise
if (prevConnectedRef.current !== isConnected) {
if (!isConnected) {
log(`${logPrefix} Not connected to external dashboard`, enableLogs);
}
else {
log(`${deviceName} Connected to external dashboard`, enableLogs);
}
prevConnectedRef.current = isConnected;
}
// Send updated env vars if they changed after connection (for failsafe scenarios)
if (isConnected && socket && mergedEnvVariables) {
const currentEnvVarsKey = JSON.stringify(mergedEnvVariables);
const prevEnvVarsKey = JSON.stringify(prevEnvVarsRef.current);
if (currentEnvVarsKey !== prevEnvVarsKey &&
Object.keys(mergedEnvVariables).length >
Object.keys(prevEnvVarsRef.current).length) {
log(`${deviceName} Sending updated environment variables to dashboard (post-failsafe)`, enableLogs);
socket.emit("env-vars-update", {
deviceId,
envVariables: mergedEnvVariables,
});
prevEnvVarsRef.current = Object.assign({}, mergedEnvVariables);
}
}
// Don't proceed with setting up event handlers if not connected
if (!isConnected || !socket) {
return;
}
// ==========================================================
// Event Handlers
// ==========================================================
// ==========================================================
// Handle initial state requests from dashboard
// ==========================================================
const initialStateSubscription = socket.on("request-initial-state", () => {
if (!deviceId) {
log(`${logPrefix} No persistent device ID found`, enableLogs, "warn");
return;
}
log(`${logPrefix} Dashboard is requesting initial state`, enableLogs);
const dehydratedState = Dehydrate(queryClient);
const syncMessage = {
type: "dehydrated-state",
state: dehydratedState,
isOnlineManagerOnline: onlineManager.isOnline(),
persistentDeviceId: deviceId,
};
socket.emit("query-sync", syncMessage);
log(`[${deviceName}] Sent initial state to dashboard (${dehydratedState.queries.length} queries)`, enableLogs);
});
// ==========================================================
// Online manager handler - Handle device internet connection state changes
// ==========================================================
const onlineManagerSubscription = socket.on("online-manager", (message) => {
const { action, targetDeviceId } = message;
if (!deviceId) {
log(`${logPrefix} No persistent device ID found`, enableLogs, "warn");
return;
}
// Only process if this message targets the current device
if (!shouldProcessMessage({
targetDeviceId: targetDeviceId,
currentDeviceId: deviceId,
})) {
return;
}
// Start a sync operation for this online manager action
const operationId = syncLogger.startOperation("query-action", {
deviceName,
deviceId,
platform,
}, enableLogs);
switch (action) {
case "ACTION-ONLINE-MANAGER-ONLINE": {
onlineManager.setOnline(true);
syncLogger.logQueryAction(operationId, action, "online-manager");
break;
}
case "ACTION-ONLINE-MANAGER-OFFLINE": {
onlineManager.setOnline(false);
syncLogger.logQueryAction(operationId, action, "online-manager");
break;
}
}
// Complete the operation
syncLogger.completeOperation(operationId);
});
// ==========================================================
// Query Actions handler - Process actions from the dashboard
// ==========================================================
const queryActionSubscription = socket.on("query-action", (message) => {
const { queryHash, queryKey, data, action, deviceId } = message;
if (!deviceId) {
log(`[${deviceName}] No persistent device ID found`, enableLogs, "warn");
return;
}
// Skip if not targeted at this device
if (!shouldProcessMessage({
targetDeviceId: deviceId,
currentDeviceId: deviceId,
})) {
return;
}
// Start a sync operation for this query action
const operationId = syncLogger.startOperation("query-action", {
deviceName,
deviceId,
platform,
}, enableLogs);
// If action is clear cache do the action here before moving on
if (action === "ACTION-CLEAR-MUTATION-CACHE") {
queryClient.getMutationCache().clear();
syncLogger.logQueryAction(operationId, action, "mutation-cache");
syncLogger.completeOperation(operationId);
return;
}
if (action === "ACTION-CLEAR-QUERY-CACHE") {
queryClient.getQueryCache().clear();
syncLogger.logQueryAction(operationId, action, "query-cache");
syncLogger.completeOperation(operationId);
return;
}
const activeQuery = queryClient.getQueryCache().get(queryHash);
if (!activeQuery) {
syncLogger.logError(operationId, "Query Not Found", `Query with hash ${queryHash} not found`);
// Removed redundant log for cleaner output
syncLogger.completeOperation(operationId, false);
return;
}
try {
switch (action) {
case "ACTION-DATA-UPDATE": {
// Check if this is a storage query
if (Array.isArray(queryKey) &&
queryKey.length === 3 &&
queryKey[0] === "#storage") {
const storageType = queryKey[1];
const storageKey = queryKey[2];
// Determine which storage instance to use based on storage type
let storageInstance;
switch (storageType.toLowerCase()) {
case "mmkv":
const rawMmkvStorage = mmkvStorage || storage;
storageInstance = rawMmkvStorage
? createStorageAdapter(rawMmkvStorage)
: undefined;
break;
case "asyncstorage":
case "async-storage":
case "async":
const rawAsyncStorage = asyncStorage || storage;
storageInstance = rawAsyncStorage
? createStorageAdapter(rawAsyncStorage)
: undefined;
break;
case "securestorage":
case "secure-storage":
case "secure":
const rawSecureStorage = secureStorage || storage;
storageInstance = rawSecureStorage
? createStorageAdapter(rawSecureStorage)
: undefined;
break;
default:
storageInstance = storage;
break;
}
// Log the storage update with current and new values
const currentValue = queryClient.getQueryData(queryKey);
const storageTypeForLogger = storageType.toLowerCase() === "mmkv"
? "mmkv"
: storageType.toLowerCase().includes("async")
? "asyncStorage"
: "secureStore";
syncLogger.logStorageUpdate(operationId, storageTypeForLogger, storageKey, currentValue, data);
// This is a storage query, handle it with the storage handler
const wasStorageHandled = handleStorageUpdate(queryKey, data, queryClient, storageInstance, enableLogs, deviceName);
// If storage handler couldn't handle it, fall back to regular update
if (!wasStorageHandled) {
queryClient.setQueryData(queryKey, data, {
updatedAt: Date.now(),
});
}
}
else {
// Not a storage query, handle as regular query data update
queryClient.setQueryData(queryKey, data, {
updatedAt: Date.now(),
});
}
syncLogger.logQueryAction(operationId, action, queryHash);
break;
}
case "ACTION-TRIGGER-ERROR": {
// Removed redundant log for cleaner output
const error = new Error("Unknown error from devtools");
const __previousQueryOptions = activeQuery.options;
activeQuery.setState({
status: "error",
error,
fetchMeta: Object.assign(Object.assign({}, activeQuery.state.fetchMeta), {
// @ts-expect-error This does exist
__previousQueryOptions }),
});
syncLogger.logQueryAction(operationId, action, queryHash);
break;
}
case "ACTION-RESTORE-ERROR": {
// Removed redundant log for cleaner output
queryClient.resetQueries(activeQuery);
syncLogger.logQueryAction(operationId, action, queryHash);
break;
}
case "ACTION-TRIGGER-LOADING": {
if (!activeQuery)
return;
// Removed redundant log for cleaner output
const __previousQueryOptions = activeQuery.options;
// Trigger a fetch in order to trigger suspense as well.
activeQuery.fetch(Object.assign(Object.assign({}, __previousQueryOptions), { queryFn: () => {
return new Promise(() => {
// Never resolve - simulates perpetual loading
});
}, gcTime: -1 }));
activeQuery.setState({
data: undefined,
status: "pending",
fetchMeta: Object.assign(Object.assign({}, activeQuery.state.fetchMeta), {
// @ts-expect-error This does exist
__previousQueryOptions }),
});
syncLogger.logQueryAction(operationId, action, queryHash);
break;
}
case "ACTION-RESTORE-LOADING": {
// Removed redundant log for cleaner output
const previousState = activeQuery.state;
const previousOptions = activeQuery.state.fetchMeta
? activeQuery.state.fetchMeta.__previousQueryOptions
: null;
activeQuery.cancel({ silent: true });
activeQuery.setState(Object.assign(Object.assign({}, previousState), { fetchStatus: "idle", fetchMeta: null }));
if (previousOptions) {
activeQuery.fetch(previousOptions);
}
syncLogger.logQueryAction(operationId, action, queryHash);
break;
}
case "ACTION-RESET": {
// Removed redundant log for cleaner output
queryClient.resetQueries(activeQuery);
syncLogger.logQueryAction(operationId, action, queryHash);
break;
}
case "ACTION-REMOVE": {
// Check if this is a storage query
if (Array.isArray(queryKey) &&
queryKey.length === 3 &&
queryKey[0] === "#storage") {
const storageType = queryKey[1];
const storageKey = queryKey[2];
// Determine which storage instance to use based on storage type
let storageInstance;
switch (storageType.toLowerCase()) {
case "mmkv":
const rawMmkvStorage = mmkvStorage || storage;
storageInstance = rawMmkvStorage
? createStorageAdapter(rawMmkvStorage)
: undefined;
break;
case "asyncstorage":
case "async-storage":
case "async":
const rawAsyncStorage = asyncStorage || storage;
storageInstance = rawAsyncStorage
? createStorageAdapter(rawAsyncStorage)
: undefined;
break;
case "securestorage":
case "secure-storage":
case "secure":
const rawSecureStorage = secureStorage || storage;
storageInstance = rawSecureStorage
? createStorageAdapter(rawSecureStorage)
: undefined;
break;
default:
storageInstance = storage;
break;
}
// Log the storage removal
const currentValue = queryClient.getQueryData(queryKey);
const storageTypeForLogger = storageType.toLowerCase() === "mmkv"
? "mmkv"
: storageType.toLowerCase().includes("async")
? "asyncStorage"
: "secureStore";
syncLogger.logStorageUpdate(operationId, storageTypeForLogger, storageKey, currentValue, null);
// This is a storage query, handle it with the storage removal handler
const wasStorageHandled = handleStorageRemoval(queryKey, queryClient, storageInstance, enableLogs, deviceName);
// If storage handler couldn't handle it, fall back to regular removal
if (!wasStorageHandled) {
queryClient.removeQueries(activeQuery);
}
}
else {
// Not a storage query, handle as regular query removal
queryClient.removeQueries(activeQuery);
}
syncLogger.logQueryAction(operationId, action, queryHash);
break;
}
case "ACTION-REFETCH": {
// Removed redundant log for cleaner output
const promise = activeQuery.fetch();
promise.catch((error) => {
// Log fetch errors but don't propagate them
syncLogger.logError(operationId, "Refetch Error", `Refetch failed for ${queryHash}`, error);
log(`[${deviceName}] Refetch error for ${queryHash}:`, enableLogs, "error");
});
syncLogger.logQueryAction(operationId, action, queryHash);
break;
}
case "ACTION-INVALIDATE": {
// Removed redundant log for cleaner output
queryClient.invalidateQueries(activeQuery);
syncLogger.logQueryAction(operationId, action, queryHash);
break;
}
case "ACTION-ONLINE-MANAGER-ONLINE": {
// Removed redundant log for cleaner output
onlineManager.setOnline(true);
syncLogger.logQueryAction(operationId, action, "online-manager");
break;
}
case "ACTION-ONLINE-MANAGER-OFFLINE": {
// Removed redundant log for cleaner output
onlineManager.setOnline(false);
syncLogger.logQueryAction(operationId, action, "online-manager");
break;
}
}
// Complete the operation successfully
syncLogger.completeOperation(operationId);
}
catch (error) {
// Complete the operation with error
syncLogger.logError(operationId, "Action Error", `Failed to execute ${action}`, error);
syncLogger.completeOperation(operationId, false);
}
});
// ==========================================================
// Subscribe to query changes and sync to dashboard
// ==========================================================
const unsubscribe = queryClient.getQueryCache().subscribe(() => {
if (!deviceId) {
log(`${logPrefix} No persistent device ID found`, enableLogs, "warn");
return;
}
// Dehydrate the current state
const dehydratedState = Dehydrate(queryClient);
// Create sync message
const syncMessage = {
type: "dehydrated-state",
state: dehydratedState,
isOnlineManagerOnline: onlineManager.isOnline(),
persistentDeviceId: deviceId,
};
// Send message to dashboard
socket.emit("query-sync", syncMessage);
});
// ==========================================================
// Cleanup function to unsubscribe from all events
// ==========================================================
return () => {
// Removed repetitive cleanup logging for cleaner output
queryActionSubscription === null || queryActionSubscription === void 0 ? void 0 : queryActionSubscription.off();
initialStateSubscription === null || initialStateSubscription === void 0 ? void 0 : initialStateSubscription.off();
onlineManagerSubscription === null || onlineManagerSubscription === void 0 ? void 0 : onlineManagerSubscription.off();
unsubscribe();
};
}, [
queryClient,
socket,
deviceName,
isConnected,
deviceId,
enableLogs,
logPrefix,
mergedEnvVariables,
storage,
mmkvStorage,
asyncStorage,
secureStorage,
secureStorageKeys,
secureStoragePollInterval,
platform,
]);
return { connect, disconnect, isConnected, socket };
}
//# sourceMappingURL=useSyncQueriesExternal.js.map