diff --git a/package.json b/package.json index 0ce63b57d5c..d2455906c3e 100644 --- a/package.json +++ b/package.json @@ -211,7 +211,7 @@ "querystring-es3": "0.2.1", "react": "18.2.0", "react-coin-icon": "rainbow-me/react-coin-icon#06464588a3d986f6ef3a7d7341b2d7ea0c5ac50b", - "react-fast-compare": "2.0.4", + "react-fast-compare": "3.2.2", "react-fast-memo": "2.0.1", "react-flatten-children": "1.1.2", "react-freeze": "1.0.4", diff --git a/src/components/DappBrowser/Homepage.tsx b/src/components/DappBrowser/Homepage.tsx index 1d7773b82bb..2b41bb371c5 100644 --- a/src/components/DappBrowser/Homepage.tsx +++ b/src/components/DappBrowser/Homepage.tsx @@ -3,6 +3,7 @@ import React, { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } import { ScrollView, StyleSheet, View } from 'react-native'; import LinearGradient from 'react-native-linear-gradient'; import Animated, { runOnJS, useAnimatedReaction, useAnimatedStyle } from 'react-native-reanimated'; +import { triggerHaptics } from 'react-native-turbo-haptics'; import { ButtonPressAnimation } from '@/components/animations'; import { Bleed, @@ -30,9 +31,8 @@ import { getDappHost } from './handleProviderRequest'; import { uniqBy } from 'lodash'; import { DEVICE_WIDTH } from '@/utils/deviceUtils'; import { EXTRA_WEBVIEW_HEIGHT, WEBVIEW_HEIGHT } from './Dimensions'; -import { useDapps } from '@/resources/metadata/dapps'; +import { useBrowserDappsStore } from '@/resources/metadata/dapps'; import { analyticsV2 } from '@/analytics'; -import haptics from '@/utils/haptics'; import * as i18n from '@/languages'; import { useBrowserStore } from '@/state/browser/browserStore'; import { DndProvider, Draggable, DraggableGrid, DraggableGridProps, UniqueIdentifier } from '../drag-and-drop'; @@ -310,7 +310,6 @@ const Card = memo(function Card({ }) { const { isDarkMode } = useColorMode(); - const { dapps } = useDapps(); const isFavorite = useFavoriteDappsStore(state => state.isFavorite(site.url || '')); const addFavorite = useFavoriteDappsStore(state => state.addFavorite); const removeFavorite = useFavoriteDappsStore(state => state.removeFavorite); @@ -345,7 +344,7 @@ const Card = memo(function Card({ const onPressMenuItem = useCallback( async ({ nativeEvent: { actionKey } }: { nativeEvent: { actionKey: 'favorite' | 'remove' } }) => { - haptics.selection(); + triggerHaptics('selection'); if (actionKey === 'favorite') { handleFavoritePress(); } else if (actionKey === 'remove') { @@ -385,14 +384,14 @@ const Card = memo(function Card({ const iconUrl = site.image; const url = dappUrl.startsWith('http') ? dappUrl : `https://${dappUrl}`; const host = new URL(url).hostname; + const dappOverride = useBrowserDappsStore.getState().findDappByHostname(host); // πŸ‘‡ TODO: Remove this once the Uniswap logo in the dapps metadata is fixed const isUniswap = host === 'uniswap.org' || host.endsWith('.uniswap.org'); - const dappOverride = dapps.find(dapp => dapp.urlDisplay === host); if (dappOverride?.iconUrl && !isUniswap) { return dappOverride.iconUrl; } return iconUrl; - }, [dapps, site.image, site.url]); + }, [site.image, site.url]); return ( diff --git a/src/components/DappBrowser/search/results/SearchResults.tsx b/src/components/DappBrowser/search/results/SearchResults.tsx index e4e6d5c3d73..b86b5d407d2 100644 --- a/src/components/DappBrowser/search/results/SearchResults.tsx +++ b/src/components/DappBrowser/search/results/SearchResults.tsx @@ -15,7 +15,7 @@ import { SPRING_CONFIGS } from '@/components/animations/animationConfigs'; import { EasingGradient } from '@/components/easing-gradient/EasingGradient'; import { Bleed, Box, Inline, Inset, Stack, Text, TextIcon, useColorMode, useForegroundColor } from '@/design-system'; import * as i18n from '@/languages'; -import { Dapp, DappsContextProvider, useDappsContext } from '@/resources/metadata/dapps'; +import { Dapp, useBrowserDappsStore } from '@/resources/metadata/dapps'; import { THICK_BORDER_WIDTH } from '@/__swaps__/screens/Swap/constants'; import deviceUtils, { DEVICE_HEIGHT, DEVICE_WIDTH } from '@/utils/deviceUtils'; import { useBrowserContext } from '../../BrowserContext'; @@ -268,9 +268,7 @@ export const SearchResults = React.memo(function SearchResults({ goToUrl }: { go - - - + ); }); @@ -284,7 +282,7 @@ const DappsDataSync = ({ searchQuery: SharedValue; searchResults: SharedValue; }) => { - const { dapps } = useDappsContext(); + const dapps = useBrowserDappsStore(state => state.dapps); useAnimatedReaction( () => searchQuery.value.trim(), @@ -305,7 +303,7 @@ const DappsDataSync = ({ }); } }, - [] + [dapps] ); return null; diff --git a/src/resources/metadata/dapps.ts b/src/resources/metadata/dapps.ts new file mode 100644 index 00000000000..04fb08194f7 --- /dev/null +++ b/src/resources/metadata/dapps.ts @@ -0,0 +1,87 @@ +import { formatUrl } from '@/components/DappBrowser/utils'; +import { metadataClient } from '@/graphql'; +import { RainbowError, logger } from '@/logger'; +import { createQueryStore } from '@/state/internal/createQueryStore'; +import { time } from '@/utils/time'; + +export type Dapp = { + colors: { + fallback?: string | null; + primary: string; + shadow?: string | null; + }; + iconUrl: string; + isDirect?: boolean; + name: string; + report: { url: string }; + search: { + normalizedName: string; + normalizedNameTokens: string[]; + normalizedUrlTokens: string[]; + }; + shortName: string; + status: string; + trending: boolean; + url: string; + urlDisplay: string; +}; + +type DappsState = { + dapps: Dapp[]; + findDappByHostname: (hostname: string) => Dapp | undefined; +}; + +export const useBrowserDappsStore = createQueryStore( + { + fetcher: fetchDapps, + setData: ({ data, set }) => set({ dapps: data }), + cacheTime: time.weeks(1), + keepPreviousData: true, + staleTime: time.minutes(30), + }, + + (_, get) => ({ + dapps: [], + findDappByHostname: (hostname: string) => get().dapps.find(dapp => dapp.urlDisplay === hostname), + }), + + { storageKey: 'browserDapps' } +); + +async function fetchDapps(): Promise { + try { + const response = await metadataClient.getdApps(); + + if (!response || !response.dApps) return []; + + return response.dApps + .filter(dapp => dapp && dapp.status !== 'SCAM') + .map(dapp => + dapp + ? { + colors: { primary: dapp.colors.primary, fallback: dapp.colors.fallback, shadow: dapp.colors.shadow }, + iconUrl: dapp.iconURL, + name: dapp.name, + report: { url: dapp.report.url }, + search: { + normalizedName: dapp.name.toLowerCase().split(' ').filter(Boolean).join(' '), + normalizedNameTokens: dapp.name.toLowerCase().split(' ').filter(Boolean), + normalizedUrlTokens: dapp.url + .toLowerCase() + .replace(/(^\w+:|^)\/\//, '') // Remove protocol from URL + .split(/\/|\?|&|=|\./) // Split the URL into tokens + .filter(Boolean), + }, + shortName: dapp.shortName, + status: dapp.status, + trending: dapp.trending || false, + url: dapp.url, + urlDisplay: formatUrl(dapp.url), + } + : ({} as Dapp) + ); + } catch (e: unknown) { + logger.error(new RainbowError('[dapps]: Failed to fetch dApps'), { message: e instanceof Error ? e.message : 'Unknown error' }); + return []; + } +} diff --git a/src/resources/metadata/dapps.tsx b/src/resources/metadata/dapps.tsx deleted file mode 100644 index 0369e933c0a..00000000000 --- a/src/resources/metadata/dapps.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { formatUrl } from '@/components/DappBrowser/utils'; -import { metadataClient } from '@/graphql'; -import { RainbowError, logger } from '@/logger'; -import { createQueryKey } from '@/react-query'; -import { useQuery, UseQueryOptions } from '@tanstack/react-query'; -import React, { createContext, useContext } from 'react'; - -export type Dapp = { - name: string; - shortName: string; - description: string; - url: string; - urlDisplay: string; - iconUrl: string; - status: string; - trending: boolean; - colors: { - primary: string; - fallback?: string | null; - shadow?: string | null; - }; - report: { url: string }; - isDirect?: boolean; - search: { - normalizedName: string; - normalizedNameTokens: string[]; - normalizedUrlTokens: string[]; - }; -}; - -const QUERY_KEY = createQueryKey('dApps', {}, { persisterVersion: 2 }); - -export function useDapps(config?: UseQueryOptions): { dapps: Dapp[] } { - const query = useQuery( - QUERY_KEY, - async () => { - try { - const response = await metadataClient.getdApps(); - if (!response || !response.dApps) { - return []; - } - - return response.dApps - .filter(dapp => dapp && dapp.status !== 'SCAM') - .map(dapp => ({ - name: dapp!.name, - shortName: dapp!.shortName, - description: '', // Remove to cut down on total object size - trending: dapp!.trending || false, - url: dapp!.url, - urlDisplay: formatUrl(dapp!.url), - iconUrl: dapp!.iconURL, - status: dapp!.status, - colors: { primary: dapp!.colors.primary, fallback: dapp!.colors.fallback, shadow: dapp!.colors.shadow }, - report: { url: dapp!.report.url }, - search: { - normalizedName: dapp!.name.toLowerCase().split(' ').filter(Boolean).join(' '), - normalizedNameTokens: dapp!.name.toLowerCase().split(' ').filter(Boolean), - normalizedUrlTokens: dapp!.url - .toLowerCase() - .replace(/(^\w+:|^)\/\//, '') // Remove protocol from URL - .split(/\/|\?|&|=|\./) // Split the URL into tokens - .filter(Boolean), - }, - })); - } catch (e: any) { - logger.error(new RainbowError('[dapps]: Failed to fetch dApps'), { message: e.message }); - return []; - } - }, - { - staleTime: 1000 * 60 * 20, // 20 minutes - cacheTime: 1000 * 60 * 60 * 24 * 2, // 2 days - retry: 3, - keepPreviousData: true, - ...config, - } - ); - - return { dapps: query.data ?? [] }; -} - -interface DappsContextType { - dapps: Dapp[]; -} - -const DappsContext = createContext(undefined); - -export const useDappsContext = () => { - const context = useContext(DappsContext); - if (!context) { - throw new Error('useDappsContext must be used within a DappsContextProvider'); - } - return context; -}; - -export const DappsContextProvider = ({ children }: { children: React.ReactNode }) => { - const { dapps } = useDapps(); - return {children}; -}; diff --git a/src/state/internal/createQueryStore.ts b/src/state/internal/createQueryStore.ts new file mode 100644 index 00000000000..19dbb811f65 --- /dev/null +++ b/src/state/internal/createQueryStore.ts @@ -0,0 +1,1028 @@ +import equal from 'react-fast-compare'; +import { StateCreator, StoreApi, UseBoundStore, create } from 'zustand'; +import { subscribeWithSelector } from 'zustand/middleware'; +import { IS_DEV } from '@/env'; +import { RainbowError, logger } from '@/logger'; +import { time } from '@/utils'; +import { RainbowPersistConfig, createRainbowStore, omitStoreMethods } from './createRainbowStore'; +import { $, AttachValue, SignalFunction, Unsubscribe, attachValueSubscriptionMap } from './signal'; + +/** + * A set of constants representing the various stages of a query's remote data fetching process. + */ +export const QueryStatuses = { + Error: 'error', + Idle: 'idle', + Loading: 'loading', + Success: 'success', +} as const; + +/** + * Represents the current status of the query's remote data fetching operation. + * + * Possible values: + * - **`'error'`** : The most recent request encountered an error. + * - **`'idle'`** : No request in progress, no error, no data yet. + * - **`'loading'`** : A request is currently in progress. + * - **`'success'`** : The most recent request has succeeded and data is available. + */ +export type QueryStatus = (typeof QueryStatuses)[keyof typeof QueryStatuses]; + +/** + * Expanded status information for the currently specified query parameters. + */ +export type QueryStatusInfo = { + isError: boolean; + isFetching: boolean; + isIdle: boolean; + isInitialLoading: boolean; + isSuccess: boolean; +}; + +/** + * Defines additional options for a data fetch operation. + */ +interface FetchOptions { + /** + * Overrides the default cache duration for this fetch, in milliseconds. + * If data in the cache is older than this duration, it will be considered expired and + * will be pruned following a successful fetch. + */ + cacheTime?: number; + /** + * Forces a fetch request even if the current data is fresh and not stale. + * If `true`, the fetch operation bypasses existing cached data. + */ + force?: boolean; + /** + * If `true`, the fetch will simply return the data without any internal handling or side effects, + * running in parallel with any other ongoing fetches. Use together with `force: true` if you want to + * guarantee that a fresh fetch is triggered regardless of the current store state. + * @default false + */ + skipStoreUpdates?: boolean; + /** + * Overrides the default stale duration for this fetch, in milliseconds. + * If the fetch is successful, the subsequently scheduled refetch will occur after + * the specified duration. + */ + staleTime?: number; +} + +/** + * Represents an entry in the query cache, which stores fetched data along with metadata, and error information + * in the event the most recent fetch failed. + */ +interface CacheEntry { + cacheTime: number; + data: TData | null; + errorInfo: { + error: Error; + lastFailedAt: number; + retryCount: number; + } | null; + lastFetchedAt: number; +} + +/** + * A specialized store interface that combines Zustand's store capabilities with remote data fetching support. + * + * In addition to Zustand's store API (such as `getState()` and `subscribe()`), this interface provides: + * - **`enabled`**: A boolean indicating if the store is actively fetching data. + * - **`queryKey`**: A string representation of the current query parameter values. + * - **`fetch(params, options)`**: Initiates a data fetch operation. + * - **`getData(params)`**: Returns the cached data, if available, for the current query parameters. + * - **`getStatus()`**: Returns expanded status information for the current query parameters. + * - **`isDataExpired(override?)`**: Checks if the current data has expired based on `cacheTime`. + * - **`isStale(override?)`**: Checks if the current data is stale based on `staleTime`. + * - **`reset()`**: Resets the store to its initial state, clearing data and errors. + */ +export interface QueryStore< + TData, + TParams extends Record, + S extends Omit, keyof PrivateStoreState>, +> extends UseBoundStore> { + /** + * Indicates whether the store should actively fetch data. + * When `false`, the store won't automatically refetch data. + */ + enabled: boolean; + /** + * The current query key, which is a string representation of the current query parameter values. + */ + queryKey: string; + /** + * Initiates a data fetch for the given parameters. If no parameters are provided, the store's + * current parameters are used. + * @param params - Optional parameters to pass to the fetcher function. + * @param options - Optional {@link FetchOptions} to customize the fetch behavior. + * @returns A promise that resolves when the fetch operation completes. + */ + fetch: (params?: TParams, options?: FetchOptions) => Promise; + /** + * Returns the cached data, if available, for the current query params. + * @returns The cached data, or `null` if no data is available. + */ + getData: (params?: TParams) => TData | null; + /** + * Returns expanded status information for the currently specified query parameters. The raw + * status can be obtained by directly reading the `status` property. + * @example + * ```ts + * const isInitialLoad = useQueryStore(state => state.getStatus().isInitialLoad); + * ``` + * @returns An object containing boolean flags for each status. + */ + getStatus: () => QueryStatusInfo; + /** + * Determines if the current data is expired based on whether `cacheTime` has been exceeded. + * @param override - An optional override for the default cache time, in milliseconds. + * @returns `true` if the data is expired, otherwise `false`. + */ + isDataExpired: (override?: number) => boolean; + /** + * Determines if the current data is stale based on whether `staleTime` has been exceeded. + * Stale data may be refreshed automatically in the background. + * @param override - An optional override for the default stale time, in milliseconds. + * @returns `true` if the data is stale, otherwise `false`. + */ + isStale: (override?: number) => boolean; + /** + * Resets the store to its initial state, clearing data, error, and any cached values. + */ + reset: () => void; +} + +/** + * The private state managed by the query store, omitted from the store's public interface. + */ +type PrivateStoreState = { + subscriptionCount: number; +}; + +/** + * The full state structure managed by the query store. This type is generally internal, + * though the state it defines can be accessed via the store's public interface. + */ +type StoreState> = Pick< + QueryStore>, + 'enabled' | 'queryKey' | 'fetch' | 'getData' | 'getStatus' | 'isDataExpired' | 'isStale' | 'reset' +> & { + error: Error | null; + lastFetchedAt: number | null; + queryCache: Record | undefined>; + status: QueryStatus; +}; + +/** + * Configuration options for creating a query-enabled Rainbow store. + */ +export type QueryStoreConfig, TData, S extends StoreState> = { + /** + * A function responsible for fetching data from a remote source. + * Receives parameters of type `TParams` and returns either a promise or a raw data value of type `TQueryFnData`. + */ + fetcher: (params: TParams, abortController: AbortController | null) => TQueryFnData | Promise; + /** + * A callback invoked whenever a fetch operation fails. + * Receives the error and the current retry count. + */ + onError?: (error: Error, retryCount: number) => void; + /** + * A callback invoked whenever fresh data is successfully fetched. + * Receives the transformed data and the store's set function, which can optionally be used to update store state. + */ + onFetched?: (info: { + data: TData; + fetch: (params?: TParams, options?: FetchOptions) => Promise; + params: TParams; + set: (partial: S | Partial | ((state: S) => S | Partial)) => void; + }) => void; + /** + * A function that overrides the default behavior of setting the fetched data in the store's query cache. + * Receives an object containing the transformed data, the query parameters, the query key, and the store's set function. + * + * When using `setData`, it’s important to note that you are taking full responsibility for managing query data. If your + * query supports variable parameters (and thus multiple query keys) and you want to cache data for each key, you’ll need + * to manually handle storing data based on the provided `params` or `queryKey`. Naturally, you will also bear + * responsibility for pruning this data in the event you do not want it persisted indefinitely. + * + * Automatic refetching per your specified `staleTime` is still managed internally by the store. While no query *data* + * will be cached internally if `setData` is provided, metadata such as the last fetch time for each query key is still + * cached and tracked by the store, unless caching is fully disabled via `disableCache: true`. + */ + setData?: (info: { + data: TData; + params: TParams; + queryKey: string; + set: (partial: S | Partial | ((state: S) => S | Partial)) => void; + }) => void; + /** + * A function to transform the raw fetched data (`TQueryFnData`) into another form (`TData`). + * If not provided, the raw data returned by `fetcher` is used. + */ + transform?: (data: TQueryFnData, params: TParams) => TData; + /** + * If `true`, the store will abort any partially completed fetches when: + * - A new fetch is initiated due to a change in parameters + * - All components subscribed to the store via selectors are unmounted + * @default true + */ + abortInterruptedFetches?: boolean; + /** + * The maximum duration, in milliseconds, that fetched data is considered fresh. + * After this time, data is considered expired and will be refetched when requested. + * @default time.days(7) + */ + cacheTime?: number | ((params: TParams) => number); + /** + * If `true`, the store will log debug messages to the console. + * @default false + */ + debugMode?: boolean; + /** + * If `true`, the store will **not** trigger automatic refetches when data becomes stale. This is + * useful in cases where you want to refetch data on component mount if stale, but not automatically + * if data becomes stale while your component is already mounted. + * @default false + */ + disableAutoRefetching?: boolean; + /** + * Controls whether the store's caching mechanisms are disabled. When disabled, the store will always refetch + * data when params change, and fetched data will not be stored unless a `setData` function is provided. + * @default false + */ + disableCache?: boolean; + /** + * When `true`, the store actively fetches and refetches data as needed. + * When `false`, the store will not automatically fetch data until explicitly enabled. + * @default true + */ + enabled?: boolean; + /** + * If `true`, the store's `getData` method will always return existing data from the cache if it exists, + * regardless of whether the cached data is expired, until the data is pruned following a successful fetch. + * + * Additionally, when params change while the store is enabled, `getData` will return the previous data until + * data for the new params is available. + * @default false + */ + keepPreviousData?: boolean; + /** + * The maximum number of times to retry a failed fetch operation. + * @default 3 + */ + maxRetries?: number; + /** + * Parameters to be passed to the fetcher, defined as either direct values or `ParamResolvable` functions. + * Dynamic parameters using `AttachValue` will cause the store to refetch when their values change. + */ + params?: { + [K in keyof TParams]: ParamResolvable; + }; + /** + * The delay between retries after a fetch error occurs, in milliseconds, defined as a number or a function that + * receives the error and current retry count and returns a number. + * @default time.seconds(5) + */ + retryDelay?: number | ((retryCount: number, error: Error) => number); + /** + * The duration, in milliseconds, that data is considered fresh after fetching. + * After becoming stale, the store may automatically refetch data in the background if there are active subscribers. + * + * **Note:** Stale times under 5 seconds are strongly discouraged. + * @default time.minutes(2) + */ + staleTime?: number; + /** + * Suppresses warnings in the event a `staleTime` under the minimum is desired. + * @default false + */ + suppressStaleTimeWarning?: boolean; +}; + +/** + * Represents a parameter that can be provided directly or defined via a reactive `AttachValue`. + * A parameter can be: + * - A static value (e.g. `string`, `number`). + * - A function that returns an `AttachValue` when given a `SignalFunction`. + */ +type ParamResolvable, S extends StoreState, TData> = + | T + | (($: SignalFunction, store: QueryStore) => AttachValue); + +interface ResolvedParamsResult { + /** + * Direct, non-reactive values resolved from the initial configuration. + */ + directValues: Partial; + /** + * Reactive parameter values wrapped in `AttachValue`, which trigger refetches when they change. + */ + paramAttachVals: Partial>>; + /** + * Fully resolved parameters, merging both direct and reactive values. + */ + resolvedParams: TParams; +} + +/** + * The keys that make up the internal state of the store. + */ +type InternalStateKeys = keyof (StoreState> & PrivateStoreState); + +const [persist, discard] = [true, false]; + +const SHOULD_PERSIST_INTERNAL_STATE_MAP: Record = { + /* Internal state to persist if the store is persisted */ + enabled: persist, + error: persist, + lastFetchedAt: persist, + queryCache: persist, + queryKey: persist, + status: persist, + + /* Internal state and methods to discard */ + fetch: discard, + getData: discard, + getStatus: discard, + isDataExpired: discard, + isStale: discard, + reset: discard, + subscriptionCount: discard, +} satisfies Record; + +const ABORT_ERROR = new Error('[createQueryStore: AbortError] Fetch interrupted'); + +const MIN_STALE_TIME = time.seconds(5); + +export function createQueryStore< + TQueryFnData, + TParams extends Record = Record, + U = unknown, + TData = TQueryFnData, +>( + config: QueryStoreConfig & PrivateStoreState & U> & { + params?: { [K in keyof TParams]: ParamResolvable & PrivateStoreState & U, TData> }; + }, + customStateCreator: StateCreator, + persistConfig?: RainbowPersistConfig & PrivateStoreState & U> +): QueryStore & U>; + +export function createQueryStore< + TQueryFnData, + TParams extends Record = Record, + U = unknown, + TData = TQueryFnData, +>( + config: QueryStoreConfig & PrivateStoreState & U> & { + params?: { [K in keyof TParams]: ParamResolvable & PrivateStoreState & U, TData> }; + }, + persistConfig?: RainbowPersistConfig & PrivateStoreState & U> +): QueryStore & U>; + +/** + * Creates a query-enabled Rainbow store with data fetching capabilities. + * @template TQueryFnData - The raw data type returned by the fetcher + * @template TParams - Parameters passed to the fetcher function + * @template U - User-defined custom store state + * @template TData - The transformed data type, if applicable (defaults to `TQueryFnData`) + */ +export function createQueryStore< + TQueryFnData, + TParams extends Record = Record, + U = unknown, + TData = TQueryFnData, +>( + config: QueryStoreConfig & PrivateStoreState & U> & { + params?: { [K in keyof TParams]: ParamResolvable & PrivateStoreState & U, TData> }; + }, + arg1?: + | StateCreator + | RainbowPersistConfig & PrivateStoreState & U>, + arg2?: RainbowPersistConfig & PrivateStoreState & U> +): QueryStore & U> { + type S = StoreState & PrivateStoreState & U; + + /* If arg1 is a function, it's the customStateCreator; otherwise, it's the persistConfig. */ + const customStateCreator = typeof arg1 === 'function' ? arg1 : () => ({}) as U; + const persistConfig = typeof arg1 === 'object' ? arg1 : arg2; + + const { + abortInterruptedFetches = true, + fetcher, + onFetched, + transform, + cacheTime = time.days(7), + debugMode = false, + disableAutoRefetching = false, + disableCache = false, + enabled = true, + keepPreviousData = false, + maxRetries = 3, + onError, + params, + retryDelay = time.seconds(5), + setData, + staleTime = time.minutes(2), + suppressStaleTimeWarning = false, + } = config; + + if (IS_DEV && !suppressStaleTimeWarning && staleTime < MIN_STALE_TIME) { + console.warn( + `[createQueryStore${persistConfig?.storageKey ? `: ${persistConfig.storageKey}` : ''}] ❌ Stale times under ${ + MIN_STALE_TIME / 1000 + } seconds are not recommended.` + ); + } + + let directValues: Partial | null = null; + let paramAttachVals: Partial>> | null = null; + let paramUnsubscribes: Unsubscribe[] = []; + let fetchAfterParamCreation = false; + + let activeAbortController: AbortController | null = null; + let activeFetch: { key: string; promise?: Promise } | null = null; + let activeRefetchTimeout: NodeJS.Timeout | null = null; + let lastFetchKey: string | null = null; + + const enableLogs = IS_DEV && debugMode; + const cacheTimeIsFunction = typeof cacheTime === 'function'; + + const initialData = { + enabled, + error: null, + lastFetchedAt: null, + queryCache: {}, + queryKey: '', + status: QueryStatuses.Idle, + subscriptionCount: 0, + }; + + const getQueryKey = (params: TParams): string => JSON.stringify(Object.values(params)); + + const getCurrentResolvedParams = () => { + const currentParams: Partial = directValues ? { ...directValues } : {}; + for (const k in paramAttachVals) { + const attachVal = paramAttachVals[k as keyof TParams]; + if (!attachVal) continue; + currentParams[k as keyof TParams] = attachVal.value as TParams[keyof TParams]; + } + return currentParams as TParams; + }; + + const fetchWithAbortControl = async (params: TParams): Promise => { + const abortController = new AbortController(); + activeAbortController = abortController; + + try { + return await new Promise((resolve, reject) => { + abortController.signal.addEventListener('abort', () => reject(ABORT_ERROR), { once: true }); + + Promise.resolve(fetcher(params, abortController)).then(resolve, reject); + }); + } finally { + if (activeAbortController === abortController) { + activeAbortController = null; + } + } + }; + + const abortActiveFetch = () => { + if (activeAbortController) { + activeAbortController.abort(); + activeAbortController = null; + } + }; + + const createState: StateCreator = (set, get, api) => { + const scheduleNextFetch = (params: TParams, options: FetchOptions | undefined) => { + if (disableAutoRefetching) return; + const effectiveStaleTime = options?.staleTime ?? staleTime; + if (effectiveStaleTime <= 0 || effectiveStaleTime === Infinity) return; + + if (activeRefetchTimeout) { + clearTimeout(activeRefetchTimeout); + activeRefetchTimeout = null; + } + + const currentQueryKey = getQueryKey(params); + const lastFetchedAt = + (disableCache ? lastFetchKey === currentQueryKey && get().lastFetchedAt : get().queryCache[currentQueryKey]?.lastFetchedAt) || null; + const timeUntilRefetch = lastFetchedAt ? effectiveStaleTime - (Date.now() - lastFetchedAt) : effectiveStaleTime; + + activeRefetchTimeout = setTimeout(() => { + const { enabled, fetch, subscriptionCount } = get(); + if (enabled && subscriptionCount > 0) { + fetch(params, { force: true }); + } + }, timeUntilRefetch); + }; + + const baseMethods = { + async fetch(params: TParams | undefined, options: FetchOptions | undefined) { + const { enabled, error, status } = get(); + + if (!options?.force && !enabled) return null; + + const effectiveParams = params ?? getCurrentResolvedParams(); + const currentQueryKey = getQueryKey(effectiveParams); + const isLoading = status === QueryStatuses.Loading; + const skipStoreUpdates = !!options?.skipStoreUpdates; + + if (activeFetch?.promise && activeFetch.key === currentQueryKey && isLoading && !options?.force) { + return activeFetch.promise; + } + + if (abortInterruptedFetches && !skipStoreUpdates) abortActiveFetch(); + + if (!options?.force) { + /* Check for valid cached data */ + const { + lastFetchedAt: storeLastFetchedAt, + queryCache: { [currentQueryKey]: cacheEntry }, + queryKey: storeQueryKey, + subscriptionCount, + } = get(); + + const { errorInfo, lastFetchedAt: cachedLastFetchedAt } = cacheEntry ?? {}; + const errorRetriesExhausted = errorInfo && errorInfo.retryCount >= maxRetries; + const lastFetchedAt = (disableCache ? lastFetchKey === currentQueryKey && storeLastFetchedAt : cachedLastFetchedAt) || null; + const isStale = !lastFetchedAt || Date.now() - lastFetchedAt >= (options?.staleTime ?? staleTime); + + if (!isStale && (!errorInfo || errorRetriesExhausted)) { + if (!activeRefetchTimeout && subscriptionCount > 0 && staleTime !== 0 && staleTime !== Infinity) { + scheduleNextFetch(effectiveParams, options); + } + if (enableLogs) console.log('[πŸ’Ύ Returning Cached Data πŸ’Ύ] for params:', JSON.stringify(effectiveParams)); + if (keepPreviousData && storeQueryKey !== currentQueryKey) set(state => ({ ...state, queryKey: currentQueryKey })); + return cacheEntry?.data ?? null; + } + } + + if (!skipStoreUpdates) { + if (error || !isLoading) set(state => ({ ...state, error: null, status: QueryStatuses.Loading })); + activeFetch = { key: currentQueryKey }; + } + + const fetchOperation = async () => { + try { + if (enableLogs) console.log('[πŸ”„ Fetching πŸ”„] for params:', JSON.stringify(effectiveParams)); + const rawResult = await (abortInterruptedFetches && !skipStoreUpdates + ? fetchWithAbortControl(effectiveParams) + : fetcher(effectiveParams, null)); + + const lastFetchedAt = Date.now(); + if (enableLogs) console.log('[βœ… Fetch Successful βœ…] for params:', JSON.stringify(effectiveParams)); + + let transformedData: TData; + try { + transformedData = transform ? transform(rawResult, effectiveParams) : (rawResult as TData); + } catch (transformError) { + throw new RainbowError(`[createQueryStore: ${persistConfig?.storageKey || currentQueryKey}]: transform failed`, { + cause: transformError, + }); + } + + if (skipStoreUpdates) { + if (enableLogs) console.log('[πŸ₯· Successful Parallel Fetch πŸ₯·] for params:', JSON.stringify(effectiveParams)); + return transformedData; + } + + set(state => { + let newState: S = { + ...state, + error: null, + lastFetchedAt, + queryKey: keepPreviousData ? currentQueryKey : state.queryKey, + status: QueryStatuses.Success, + }; + + if (!setData && !disableCache) { + if (enableLogs) + console.log( + '[πŸ’Ύ Setting Cache πŸ’Ύ] for params:', + JSON.stringify(effectiveParams), + '| Has previous data?:', + !!newState.queryCache[currentQueryKey]?.data + ); + newState.queryCache = { + ...newState.queryCache, + [currentQueryKey]: { + cacheTime: cacheTimeIsFunction ? cacheTime(effectiveParams) : cacheTime, + data: transformedData, + errorInfo: null, + lastFetchedAt, + }, + }; + } else if (setData) { + if (enableLogs) console.log('[πŸ’Ύ Setting Data πŸ’Ύ] for params:\n', JSON.stringify(effectiveParams)); + setData({ + data: transformedData, + params: effectiveParams, + queryKey: currentQueryKey, + set: (partial: S | Partial | ((state: S) => S | Partial)) => { + newState = typeof partial === 'function' ? { ...newState, ...partial(newState) } : { ...newState, ...partial }; + }, + }); + if (!disableCache) { + newState.queryCache = { + ...newState.queryCache, + [currentQueryKey]: { + cacheTime: cacheTimeIsFunction ? cacheTime(effectiveParams) : cacheTime, + data: null, + errorInfo: null, + lastFetchedAt, + }, + }; + } + } + + return disableCache || cacheTime === Infinity + ? newState + : pruneCache(keepPreviousData, currentQueryKey, newState); + }); + + lastFetchKey = currentQueryKey; + scheduleNextFetch(effectiveParams, options); + + if (onFetched) { + try { + onFetched({ data: transformedData, fetch: baseMethods.fetch, params: effectiveParams, set }); + } catch (onFetchedError) { + logger.error( + new RainbowError(`[createQueryStore: ${persistConfig?.storageKey || currentQueryKey}]: onFetched callback failed`, { + cause: onFetchedError, + }) + ); + } + } + + return transformedData ?? null; + } catch (error) { + if (error === ABORT_ERROR) { + if (enableLogs) console.log('[❌ Fetch Aborted ❌] for params:', JSON.stringify(effectiveParams)); + return null; + } + + if (skipStoreUpdates) { + logger.error(new RainbowError(`[createQueryStore: ${persistConfig?.storageKey || currentQueryKey}]: Failed to fetch data`), { + error, + }); + return null; + } + + const typedError = error instanceof Error ? error : new Error(String(error)); + const entry = disableCache ? undefined : get().queryCache[currentQueryKey]; + const currentRetryCount = entry?.errorInfo?.retryCount ?? 0; + + onError?.(typedError, currentRetryCount); + + if (currentRetryCount < maxRetries) { + if (get().subscriptionCount > 0) { + const errorRetryDelay = typeof retryDelay === 'function' ? retryDelay(currentRetryCount, typedError) : retryDelay; + if (errorRetryDelay !== Infinity) { + activeRefetchTimeout = setTimeout(() => { + const { enabled, fetch, subscriptionCount } = get(); + if (enabled && subscriptionCount > 0) { + fetch(params, { force: true }); + } + }, errorRetryDelay); + } + } + + set(state => ({ + ...state, + error: typedError, + queryCache: { + ...state.queryCache, + [currentQueryKey]: { + ...entry, + errorInfo: { + error: typedError, + lastFailedAt: Date.now(), + retryCount: currentRetryCount + 1, + }, + }, + }, + queryKey: keepPreviousData ? currentQueryKey : state.queryKey, + status: QueryStatuses.Error, + })); + } else { + /* Max retries exhausted */ + set(state => ({ + ...state, + error: typedError, + queryCache: { + ...state.queryCache, + [currentQueryKey]: { + ...entry, + errorInfo: { + error: typedError, + lastFailedAt: Date.now(), + retryCount: maxRetries, + }, + }, + }, + queryKey: keepPreviousData ? currentQueryKey : state.queryKey, + status: QueryStatuses.Error, + })); + } + + logger.error(new RainbowError(`[createQueryStore: ${persistConfig?.storageKey || currentQueryKey}]: Failed to fetch data`), { + error, + }); + + return null; + } finally { + if (!skipStoreUpdates) activeFetch = null; + } + }; + + if (skipStoreUpdates) return fetchOperation(); + + return (activeFetch = { key: currentQueryKey, promise: fetchOperation() }).promise; + }, + + getData(params?: TParams) { + if (disableCache) return null; + const currentQueryKey = params ? getQueryKey(params) : get().queryKey; + const cacheEntry = get().queryCache[currentQueryKey]; + if (keepPreviousData) return cacheEntry?.data ?? null; + const isExpired = !!cacheEntry?.lastFetchedAt && Date.now() - cacheEntry.lastFetchedAt > cacheEntry.cacheTime; + return isExpired ? null : cacheEntry?.data ?? null; + }, + + getStatus() { + const { queryKey, status } = get(); + const lastFetchedAt = + (disableCache ? lastFetchKey === queryKey && get().lastFetchedAt : get().queryCache[queryKey]?.lastFetchedAt) || null; + + return { + isError: status === QueryStatuses.Error, + isFetching: status === QueryStatuses.Loading, + isIdle: status === QueryStatuses.Idle, + isInitialLoading: !lastFetchedAt && status === QueryStatuses.Loading, + isSuccess: status === QueryStatuses.Success, + }; + }, + + isDataExpired(cacheTimeOverride?: number) { + const currentQueryKey = get().queryKey; + const { + lastFetchedAt: storeLastFetchedAt, + queryCache: { [currentQueryKey]: cacheEntry }, + } = get(); + + const lastFetchedAt = (disableCache ? lastFetchKey === currentQueryKey && storeLastFetchedAt : cacheEntry?.lastFetchedAt) || null; + if (!lastFetchedAt) return true; + + const effectiveCacheTime = cacheTimeOverride ?? cacheEntry?.cacheTime; + return effectiveCacheTime === undefined || Date.now() - lastFetchedAt > effectiveCacheTime; + }, + + isStale(staleTimeOverride?: number) { + const { queryKey } = get(); + const lastFetchedAt = + (disableCache ? lastFetchKey === queryKey && get().lastFetchedAt : get().queryCache[queryKey]?.lastFetchedAt) || null; + + if (!lastFetchedAt) return true; + const effectiveStaleTime = staleTimeOverride ?? staleTime; + return Date.now() - lastFetchedAt > effectiveStaleTime; + }, + + reset() { + if (activeRefetchTimeout) { + clearTimeout(activeRefetchTimeout); + activeRefetchTimeout = null; + } + if (abortInterruptedFetches) abortActiveFetch(); + activeFetch = null; + lastFetchKey = null; + set(state => ({ ...state, ...initialData, queryKey: getQueryKey(getCurrentResolvedParams()) })); + }, + }; + + let lastHandledEnabled: boolean | null = null; + const handleSetEnabled = (state: S, prevState: S) => { + if (state.enabled !== prevState.enabled && lastHandledEnabled !== state.enabled) { + lastHandledEnabled = state.enabled; + if (state.enabled) { + const currentParams = getCurrentResolvedParams(); + const currentKey = state.queryKey; + if (currentKey !== lastFetchKey || state.isStale()) { + state.fetch(currentParams); + } else { + scheduleNextFetch(currentParams, undefined); + } + } else if (activeRefetchTimeout) { + clearTimeout(activeRefetchTimeout); + activeRefetchTimeout = null; + } + } + }; + + const handleSubscribe = () => { + if (!directValues && !paramAttachVals && params !== undefined) { + fetchAfterParamCreation = true; + return; + } + const { enabled, fetch, isStale, queryKey: storeQueryKey, subscriptionCount } = get(); + + if (!enabled || (subscriptionCount !== 1 && !disableAutoRefetching)) return; + + if (subscriptionCount === 1) { + const currentParams = getCurrentResolvedParams(); + const currentQueryKey = getQueryKey(currentParams); + + if (storeQueryKey !== currentQueryKey) set(state => ({ ...state, queryKey: currentQueryKey })); + + if (isStale()) { + fetch(currentParams); + } else { + scheduleNextFetch(currentParams, undefined); + } + } else if (disableAutoRefetching) { + fetch(); + } + }; + + const handleUnsubscribe = (unsubscribe: () => void) => { + return () => { + unsubscribe(); + set(state => { + const newCount = Math.max(state.subscriptionCount - 1, 0); + if (newCount === 0) { + if (activeRefetchTimeout) { + clearTimeout(activeRefetchTimeout); + activeRefetchTimeout = null; + } + if (abortInterruptedFetches) abortActiveFetch(); + } + return { ...state, subscriptionCount: newCount }; + }); + }; + }; + + const incrementSubscriptionCount = () => { + set(state => ({ + ...state, + subscriptionCount: state.subscriptionCount + 1, + })); + }; + + const subscribeWithSelector = api.subscribe; + api.subscribe = (listener: (state: S, prevState: S) => void) => { + incrementSubscriptionCount(); + + const unsubscribe = subscribeWithSelector((state: S, prevState: S) => { + listener(state, prevState); + handleSetEnabled(state, prevState); + }); + + handleSubscribe(); + + return handleUnsubscribe(unsubscribe); + }; + + const userState = customStateCreator?.(set, get, api) ?? ({} as U); + + /* Merge base data, user state, and methods into the final store state */ + return { + ...initialData, + ...userState, + ...baseMethods, + }; + }; + + const combinedPersistConfig = persistConfig + ? { + ...persistConfig, + partialize: createBlendedPartialize(keepPreviousData, persistConfig.partialize), + } + : undefined; + + const baseStore = persistConfig?.storageKey + ? createRainbowStore(createState, combinedPersistConfig) + : create(subscribeWithSelector(createState)); + + const queryCapableStore: QueryStore = Object.assign(baseStore, { + enabled, + queryKey: baseStore.getState().queryKey, + fetch: (params?: TParams, options?: FetchOptions) => baseStore.getState().fetch(params, options), + getData: () => baseStore.getState().getData(), + getStatus: () => baseStore.getState().getStatus(), + isDataExpired: (override?: number) => baseStore.getState().isDataExpired(override), + isStale: (override?: number) => baseStore.getState().isStale(override), + reset: () => { + for (const unsub of paramUnsubscribes) unsub(); + paramUnsubscribes = []; + queryCapableStore.getState().reset(); + queryCapableStore.setState(state => ({ ...state, ...initialData, queryKey: getQueryKey(getCurrentResolvedParams()) })); + }, + }); + + if (params) { + const result = resolveParams(params, queryCapableStore); + paramAttachVals = result.paramAttachVals; + directValues = result.directValues; + } + + const onParamChange = () => { + const newParams = getCurrentResolvedParams(); + if (!keepPreviousData) { + const newQueryKey = getQueryKey(newParams); + queryCapableStore.setState(state => ({ ...state, queryKey: newQueryKey })); + } + queryCapableStore.fetch(newParams); + }; + + for (const k in paramAttachVals) { + const attachVal = paramAttachVals[k]; + if (!attachVal) continue; + + const subscribeFn = attachValueSubscriptionMap.get(attachVal); + if (enableLogs) console.log('[πŸŒ€ Param Subscription πŸŒ€] Subscribed to param:', k); + + if (subscribeFn) { + let oldVal = attachVal.value; + const unsub = subscribeFn(() => { + const newVal = attachVal.value; + if (!equal(oldVal, newVal)) { + if (enableLogs) console.log('[πŸŒ€ Param Change πŸŒ€] -', k, '- [Old]:', `${oldVal?.toString()},`, '[New]:', newVal?.toString()); + oldVal = newVal; + onParamChange(); + } + }); + paramUnsubscribes.push(unsub); + } + } + + if (fetchAfterParamCreation) onParamChange(); + + return queryCapableStore; +} + +function pruneCache, TData, TParams extends Record>( + keepPreviousData: boolean, + keyToPreserve: string | null, + state: S | Partial +): S | Partial { + if (!state.queryCache) return state; + const effectiveKeyToPreserve = keyToPreserve ?? (keepPreviousData ? state.queryKey ?? null : null); + const newCache: Record> = {}; + const pruneTime = Date.now(); + + Object.entries(state.queryCache).forEach(([key, entry]) => { + if (entry && (pruneTime - entry.lastFetchedAt <= entry.cacheTime || key === effectiveKeyToPreserve)) { + newCache[key] = entry; + } + }); + + return { ...state, queryCache: newCache }; +} + +function resolveParams, S extends StoreState & U, TData, U = unknown>( + params: { [K in keyof TParams]: ParamResolvable }, + store: QueryStore +): ResolvedParamsResult { + const directValues: Partial = {}; + const paramAttachVals: Partial>> = {}; + const resolvedParams = {} as TParams; + + for (const key in params) { + const param = params[key]; + if (typeof param === 'function') { + const attachVal = param($, store); + resolvedParams[key] = attachVal.value as TParams[typeof key]; + paramAttachVals[key] = attachVal; + } else { + resolvedParams[key] = param as TParams[typeof key]; + directValues[key] = param as TParams[typeof key]; + } + } + + return { directValues, paramAttachVals, resolvedParams }; +} + +function createBlendedPartialize, S extends StoreState & U, U = unknown>( + keepPreviousData: boolean, + userPartialize: ((state: StoreState & U) => Partial & U>) | undefined +) { + return (state: S) => { + const clonedState = { ...state }; + const internalStateToPersist: Partial = {}; + + for (const key in clonedState) { + if (key in SHOULD_PERSIST_INTERNAL_STATE_MAP) { + if (SHOULD_PERSIST_INTERNAL_STATE_MAP[key]) internalStateToPersist[key] = clonedState[key]; + delete clonedState[key]; + } + } + + return { + ...(userPartialize ? userPartialize(clonedState) : omitStoreMethods(clonedState)), + ...pruneCache(keepPreviousData, null, internalStateToPersist), + } satisfies Partial; + }; +} diff --git a/src/state/internal/createRainbowStore.ts b/src/state/internal/createRainbowStore.ts index e0c14b79ead..e2751164f65 100644 --- a/src/state/internal/createRainbowStore.ts +++ b/src/state/internal/createRainbowStore.ts @@ -1,33 +1,47 @@ import { debounce } from 'lodash'; import { MMKV } from 'react-native-mmkv'; -import { create } from 'zustand'; -import { PersistOptions, StorageValue, persist, subscribeWithSelector } from 'zustand/middleware'; -import { StateCreator } from 'zustand/vanilla'; +import { StateCreator, create } from 'zustand'; +import { PersistOptions, PersistStorage, StorageValue, persist, subscribeWithSelector } from 'zustand/middleware'; +import { IS_IOS } from '@/env'; import { RainbowError, logger } from '@/logger'; -const PERSIST_RATE_LIMIT_MS = 3000; - const rainbowStorage = new MMKV({ id: 'rainbow-storage' }); /** * Configuration options for creating a persistable Rainbow store. */ -interface RainbowPersistConfig { +export interface RainbowPersistConfig> { /** * A function to convert the serialized string back into the state object. * If not provided, the default deserializer is used. */ - deserializer?: (serializedState: string) => StorageValue>; + deserializer?: (serializedState: string) => StorageValue; + /** + * A function to perform persisted state migration. + * This function will be called when persisted state versions mismatch with the one specified here. + */ + migrate?: PersistOptions['migrate']; + /** + * A function returning another (optional) function. + * The main function will be called before the state rehydration. + * The returned function will be called after the state rehydration or when an error occurred. + */ + onRehydrateStorage?: PersistOptions['onRehydrateStorage']; /** * A function that determines which parts of the state should be persisted. * By default, the entire state is persisted. */ - partialize?: (state: S) => Partial; + partialize?: (state: S) => PersistedState; /** * A function to serialize the state and version into a string for storage. * If not provided, the default serializer is used. */ - serializer?: (state: StorageValue>['state'], version: StorageValue>['version']) => string; + serializer?: (state: StorageValue['state'], version: StorageValue['version']) => string; + /** + * The throttle rate for the persist operation in milliseconds. + * @default iOS: time.seconds(3) | Android: time.seconds(5) + */ + persistThrottleMs?: number; /** * The unique key for the persisted store. */ @@ -38,41 +52,107 @@ interface RainbowPersistConfig { * @default 0 */ version?: number; - /** - * A function to perform persisted state migration. - * This function will be called when persisted state versions mismatch with the one specified here. - */ - migrate?: (persistedState: unknown, version: number) => S | Promise; - /** - * A function returning another (optional) function. - * The main function will be called before the state rehydration. - * The returned function will be called after the state rehydration or when an error occurred. - */ - onRehydrateStorage?: PersistOptions>['onRehydrateStorage']; } +/** + * Creates a Rainbow store with optional persistence functionality. + * @param createState - The state creator function for the Rainbow store. + * @param persistConfig - The configuration options for the persistable Rainbow store. + * @returns A Zustand store with the specified state and optional persistence. + */ +export function createRainbowStore = Partial>( + createState: StateCreator, + persistConfig?: RainbowPersistConfig +) { + if (!persistConfig) return create()(subscribeWithSelector(createState)); + + const { persistStorage, version } = createPersistStorage(persistConfig); + + return create()( + subscribeWithSelector( + persist(createState, { + migrate: persistConfig.migrate, + name: persistConfig.storageKey, + onRehydrateStorage: persistConfig.onRehydrateStorage, + storage: persistStorage, + version, + }) + ) + ); +} + +/** + * Default partialize function if none is provided. It omits top-level store + * methods and keeps all other state. + */ +export function omitStoreMethods>(state: S): PersistedState { + if (state !== null && typeof state === 'object') { + const result: Record = {}; + Object.entries(state).forEach(([key, val]) => { + if (typeof val !== 'function') { + result[key] = val; + } + }); + return result as PersistedState; + } + return state as unknown as PersistedState; +} + +interface LazyPersistParams> { + name: string; + partialize: NonNullable['partialize']>; + serializer: NonNullable['serializer']>; + storageKey: string; + value: StorageValue | StorageValue; +} + +const DEFAULT_PERSIST_THROTTLE_MS = IS_IOS ? 3000 : 5000; + /** * Creates a persist storage object for the Rainbow store. * @param config - The configuration options for the persistable Rainbow store. * @returns An object containing the persist storage and version. */ -function createPersistStorage(config: RainbowPersistConfig) { - const { deserializer = defaultDeserializeState, serializer = defaultSerializeState, storageKey, version = 0 } = config; +function createPersistStorage>(config: RainbowPersistConfig) { + const enableMapSetHandling = !config.deserializer && !config.serializer; + const { + deserializer = serializedState => defaultDeserializeState(serializedState, enableMapSetHandling), + serializer = (state, version) => defaultSerializeState(state, version, enableMapSetHandling), + persistThrottleMs = DEFAULT_PERSIST_THROTTLE_MS, + storageKey, + version = 0, + } = config; + + const lazyPersist = debounce( + function persist(params: LazyPersistParams): void { + try { + const key = `${params.storageKey}:${params.name}`; + const serializedValue = params.serializer(params.partialize(params.value.state as S), params.value.version ?? 0); + rainbowStorage.set(key, serializedValue); + } catch (error) { + logger.error(new RainbowError(`[createRainbowStore]: Failed to serialize persisted store data`), { error }); + } + }, + persistThrottleMs, + { leading: false, trailing: true, maxWait: persistThrottleMs } + ); - const persistStorage: PersistOptions>['storage'] = { + const persistStorage: PersistStorage = { getItem: (name: string) => { const key = `${storageKey}:${name}`; const serializedValue = rainbowStorage.getString(key); if (!serializedValue) return null; return deserializer(serializedValue); }, - setItem: (name, value) => + setItem: (name, value) => { lazyPersist({ + partialize: config.partialize ?? omitStoreMethods, serializer, storageKey, name, value, - }), + }); + }, removeItem: (name: string) => { const key = `${storageKey}:${name}`; rainbowStorage.delete(key); @@ -82,43 +162,19 @@ function createPersistStorage(config: RainbowPersistConfig) { return { persistStorage, version }; } -interface LazyPersistParams { - name: string; - serializer: (state: StorageValue>['state'], version: StorageValue>['version']) => string; - storageKey: string; - value: StorageValue>; -} - -/** - * Initiates a debounced persist operation for a given store state. - * @param storageKey - The key prefix for the store in the central MMKV storage. - * @param name - The name of the store. - * @param value - The state value to be persisted. - */ -const lazyPersist = ({ name, serializer, storageKey, value }: LazyPersistParams) => - debounce( - () => { - try { - const key = `${storageKey}:${name}`; - const serializedValue = serializer(value.state, value.version ?? 0); - rainbowStorage.set(key, serializedValue); - } catch (error) { - logger.error(new RainbowError(`[createRainbowStore]: Failed to serialize persisted store data`), { error }); - } - }, - PERSIST_RATE_LIMIT_MS, - { leading: false, trailing: true, maxWait: PERSIST_RATE_LIMIT_MS } - )(); - /** * Serializes the state and version into a JSON string. * @param state - The state to be serialized. * @param version - The version of the state. * @returns The serialized state as a JSON string. */ -function defaultSerializeState(state: StorageValue>['state'], version: StorageValue>['version']): string { +function defaultSerializeState( + state: StorageValue['state'], + version: StorageValue['version'], + shouldUseReplacer: boolean +): string { try { - return JSON.stringify({ state, version }); + return JSON.stringify({ state, version }, shouldUseReplacer ? replacer : undefined); } catch (error) { logger.error(new RainbowError(`[createRainbowStore]: Failed to serialize Rainbow store data`), { error }); throw error; @@ -130,41 +186,53 @@ function defaultSerializeState(state: StorageValue>['state'], vers * @param serializedState - The serialized state as a JSON string. * @returns An object containing the deserialized state and version. */ -function defaultDeserializeState(serializedState: string): StorageValue> { +function defaultDeserializeState(serializedState: string, shouldUseReviver: boolean): StorageValue { try { - return JSON.parse(serializedState); + return JSON.parse(serializedState, shouldUseReviver ? reviver : undefined); } catch (error) { logger.error(new RainbowError(`[createRainbowStore]: Failed to deserialize persisted Rainbow store data`), { error }); throw error; } } +interface MapSerialization { + __type: 'Map'; + entries: [unknown, unknown][]; +} + +function isSerializedMap(value: unknown): value is MapSerialization { + return typeof value === 'object' && value !== null && (value as Record).__type === 'Map'; +} + +interface SetSerialization { + __type: 'Set'; + values: unknown[]; +} + +function isSerializedSet(value: unknown): value is SetSerialization { + return typeof value === 'object' && value !== null && (value as Record).__type === 'Set'; +} + /** - * Creates a Rainbow store with optional persistence functionality. - * @param createState - The state creator function for the Rainbow store. - * @param persistConfig - The configuration options for the persistable Rainbow store. - * @returns A Zustand store with the specified state and optional persistence. + * Replacer function to handle serialization of Maps and Sets. */ -export function createRainbowStore( - createState: StateCreator, - persistConfig?: RainbowPersistConfig -) { - if (!persistConfig) { - return create()(subscribeWithSelector(createState)); +function replacer(key: string, value: unknown): unknown { + if (value instanceof Map) { + return { __type: 'Map', entries: Array.from(value.entries()) }; + } else if (value instanceof Set) { + return { __type: 'Set', values: Array.from(value) }; } + return value; +} - const { persistStorage, version } = createPersistStorage(persistConfig); - - return create()( - subscribeWithSelector( - persist(createState, { - name: persistConfig.storageKey, - partialize: persistConfig.partialize || (state => state), - storage: persistStorage, - version, - migrate: persistConfig.migrate, - onRehydrateStorage: persistConfig.onRehydrateStorage, - }) - ) - ); +/** + * Reviver function to handle deserialization of Maps and Sets. + */ +function reviver(key: string, value: unknown): unknown { + if (isSerializedMap(value)) { + return new Map(value.entries); + } else if (isSerializedSet(value)) { + return new Set(value.values); + } + return value; } diff --git a/src/state/internal/createStore.ts b/src/state/internal/createStore.ts index 3c49c5eb18a..05006491549 100644 --- a/src/state/internal/createStore.ts +++ b/src/state/internal/createStore.ts @@ -8,6 +8,9 @@ export type StoreWithPersist = Mutate, [['zustand/persi initializer: Initializer; }; +/** + * @deprecated This is a legacy store creator. Use `createRainbowStore` instead. + */ export function createStore( initializer: Initializer, { persist: persistOptions }: { persist?: PersistOptions } = {} diff --git a/src/state/internal/signal.ts b/src/state/internal/signal.ts new file mode 100644 index 00000000000..c3eec56f9fc --- /dev/null +++ b/src/state/internal/signal.ts @@ -0,0 +1,167 @@ +import equal from 'react-fast-compare'; +import { StoreApi } from 'zustand'; + +const ENABLE_LOGS = false; + +/* Store subscribe function so we can handle param changes on any attachVal (root or nested) */ +export const attachValueSubscriptionMap = new WeakMap, Subscribe>(); + +/* Global caching for top-level attachValues */ +const storeSignalCache = new WeakMap< + StoreApi, + Map<(state: unknown) => unknown, Map<(a: unknown, b: unknown) => boolean, AttachValue>> +>(); + +export type AttachValue = T & { value: T } & { + readonly [K in keyof T]: AttachValue; +}; + +export type SignalFunction = { + (store: StoreApi): AttachValue; + (store: StoreApi, selector: (state: T) => S, equalityFn?: (a: S, b: S) => boolean): AttachValue; +}; + +export type Unsubscribe = () => void; +export type Subscribe = (callback: () => void) => Unsubscribe; +export type GetValue = () => unknown; +export type SetValue = (path: unknown[], value: unknown) => void; + +export function $(store: StoreApi): AttachValue; +export function $(store: StoreApi, selector: (state: T) => S, equalityFn?: (a: S, b: S) => boolean): AttachValue; +export function $( + store: StoreApi, + selector: (state: unknown) => unknown = identity, + equalityFn: (a: unknown, b: unknown) => boolean = equal +) { + return getOrCreateAttachValue(store, selector, equalityFn); +} + +const identity = (x: T): T => x; + +const updateValue = (obj: T, path: unknown[], value: unknown): T => { + if (!path.length) { + return value as T; + } + const [first, ...rest] = path; + const prevValue = (obj as Record)[first as string]; + const nextValue = updateValue(prevValue, rest, value); + if (equal(prevValue, nextValue)) { + return obj; + } + const copied = Array.isArray(obj) ? obj.slice() : { ...obj }; + (copied as Record)[first as string] = nextValue; + return copied as T; +}; + +export const createSignal = ( + store: StoreApi, + selector: (state: T) => S, + equalityFn: (a: S, b: S) => boolean +): [Subscribe, GetValue, SetValue] => { + let selected = selector(store.getState()); + const listeners = new Set<() => void>(); + let unsubscribe: Unsubscribe | undefined; + + const sub: Subscribe = callback => { + if (!listeners.size) { + unsubscribe = store.subscribe(() => { + const nextSelected = selector(store.getState()); + if (!equalityFn(selected, nextSelected)) { + selected = nextSelected; + listeners.forEach(listener => listener()); + } + }); + } + listeners.add(callback); + return () => { + listeners.delete(callback); + if (!listeners.size && unsubscribe) { + unsubscribe(); + unsubscribe = undefined; + } + }; + }; + + const get: GetValue = () => { + if (!listeners.size) { + selected = selector(store.getState()); + } + return selected; + }; + + const set: SetValue = (path, value) => { + if (selector !== identity) { + throw new Error('Cannot set a value with a selector'); + } + store.setState(prev => updateValue(prev, path, value), true); + }; + + return [sub, get, set]; +}; + +function getOrCreateAttachValue(store: StoreApi, selector: (state: T) => S, equalityFn: (a: S, b: S) => boolean): AttachValue { + let bySelector = storeSignalCache.get(store); + if (!bySelector) { + bySelector = new Map(); + storeSignalCache.set(store, bySelector); + } + + let byEqFn = bySelector.get(selector as (state: unknown) => unknown); + if (!byEqFn) { + byEqFn = new Map(); + bySelector.set(selector as (state: unknown) => unknown, byEqFn); + } + + const existing = byEqFn.get(equalityFn as (a: unknown, b: unknown) => boolean); + if (existing) { + return existing as AttachValue; + } + + const [subscribe, getVal, setVal] = createSignal(store, selector, equalityFn); + + const localCache = new Map>(); + + const createAttachValue = (fullPath: string): AttachValue => { + const handler: ProxyHandler = { + get(_, key) { + if (key === 'value') { + let v = getVal(); + const parts = fullPath.split('.'); + for (const p of parts) { + if (p) v = (v as Record)[p]; + } + return v; + } + const keyString = typeof key === 'string' ? key : key.toString(); + const pathKey = fullPath ? `${fullPath}.${keyString}` : keyString; + const cached = localCache.get(pathKey); + if (cached) { + if (ENABLE_LOGS) console.log('[πŸŒ€ AttachValue πŸŒ€] Cache hit for:', pathKey); + return cached; + } else if (ENABLE_LOGS) { + console.log('[πŸŒ€ AttachValue πŸŒ€] Created root attachValue:', pathKey); + } + const val = createAttachValue(pathKey); + attachValueSubscriptionMap.set(val, subscribe); + localCache.set(pathKey, val); + return val; + }, + set(_, __, value) { + const path = fullPath.split('.'); + if (path[0] === '') path.shift(); + setVal(path, value); + return true; + }, + }; + + return new Proxy(Object.create(null), handler) as AttachValue; + }; + + const rootVal = createAttachValue(''); + subscribe(() => { + return; + }); + attachValueSubscriptionMap.set(rootVal, subscribe); + byEqFn.set(equalityFn as (a: unknown, b: unknown) => boolean, rootVal); + return rootVal as AttachValue; +} diff --git a/src/state/internal/tests/QueryStoreTest.tsx b/src/state/internal/tests/QueryStoreTest.tsx new file mode 100644 index 00000000000..ca938d21c7b --- /dev/null +++ b/src/state/internal/tests/QueryStoreTest.tsx @@ -0,0 +1,235 @@ +// ⚠️ Uncomment everything below to experiment with the QueryStore creator + +// import React, { memo, useEffect, useMemo } from 'react'; +// import { StyleSheet, View } from 'react-native'; +// import { Address } from 'viem'; +// import ButtonPressAnimation from '@/components/animations/ButtonPressAnimation'; +// import { ImgixImage } from '@/components/images'; +// import { Text, useForegroundColor } from '@/design-system'; +// import { logger, RainbowError } from '@/logger'; +// import { SupportedCurrencyKey } from '@/references'; +// import { addysHttp } from '@/resources/addys/claimables/query'; +// import { parseUserAssets } from '@/__swaps__/screens/Swap/resources/assets/userAssets'; +// import { ParsedAssetsDictByChain } from '@/__swaps__/types/assets'; +// import { AddressAssetsReceivedMessage } from '@/__swaps__/types/refraction'; +// import { useBackendNetworksStore } from '@/state/backendNetworks/backendNetworks'; +// import { time } from '@/utils'; +// import { createQueryStore } from '../createQueryStore'; +// import { createRainbowStore } from '../createRainbowStore'; + +// const ENABLE_LOGS = false; + +// type CurrencyStore = { +// nestedParamTest: { +// currency: SupportedCurrencyKey; +// }; +// setCurrency: (currency: SupportedCurrencyKey) => void; +// }; + +// const useCurrencyStore = createRainbowStore((set, get) => ({ +// nestedParamTest: { currency: 'USD' }, +// setCurrency: (currency: SupportedCurrencyKey) => { +// set({ nestedParamTest: { currency } }); +// if (ENABLE_LOGS) console.log('[πŸ‘€ useCurrencyStore πŸ‘€] New currency set:', get().nestedParamTest.currency); +// }, +// })); + +// type UserAssetsTestStore = { +// address: Address; +// setAddress: (address: Address) => void; +// }; + +// type UserAssetsQueryParams = { address: Address; currency: SupportedCurrencyKey }; + +// const testAddresses: Address[] = [ +// '0x2e67869829c734ac13723A138a952F7A8B56e774', +// '0xCFB83E14AEd465c79F3F82f4cfF4ff7965897644', +// '0x17cd072cBd45031EFc21Da538c783E0ed3b25DCc', +// ]; + +// export const useUserAssetsTestStore = createQueryStore( +// { +// fetcher: ({ address, currency }) => simpleUserAssetsQuery({ address, currency }), +// params: { +// address: ($, store) => $(store).address, +// currency: $ => $(useCurrencyStore).nestedParamTest.currency, +// }, +// staleTime: time.minutes(1), +// }, + +// set => ({ +// address: testAddresses[0], +// setAddress: (address: Address) => set({ address }), +// }), + +// { storageKey: 'queryStoreTest' } +// ); + +// export const UserAssetsTest = memo(function UserAssetsTest() { +// const data = useUserAssetsTestStore(state => state.getData()); +// const enabled = useUserAssetsTestStore(state => state.enabled); + +// const firstFiveCoinIconUrls = useMemo(() => (data ? getFirstFiveCoinIconUrls(data) : Array.from({ length: 5 }).map(() => '')), [data]); +// const skeletonColor = useForegroundColor('fillQuaternary'); + +// useEffect(() => { +// if (ENABLE_LOGS && data) { +// const first5Tokens = Object.values(data) +// .flatMap(chainAssets => Object.values(chainAssets)) +// .slice(0, 5); +// console.log('[πŸ”” UserAssetsTest πŸ””] userAssets data updated - first 5 tokens:', first5Tokens.map(token => token.symbol).join(', ')); +// } +// }, [data]); + +// useEffect(() => { +// if (ENABLE_LOGS) console.log(`[πŸ”” UserAssetsTest πŸ””] enabled updated to: ${enabled ? 'βœ… ENABLED' : 'πŸ›‘ DISABLED'}`); +// }, [enabled]); + +// return ( +// +// +// {firstFiveCoinIconUrls.map((url, index) => +// url ? ( +// +// ) : ( +// +// ) +// )} +// +// +// {data +// ? `Number of assets: ${Object.values(data).reduce((acc, chainAssets) => acc + Object.keys(chainAssets).length, 0)}` +// : 'Loading…'} +// +// +// { +// const currentAddress = useUserAssetsTestStore.getState().address; +// switch (currentAddress) { +// case testAddresses[0]: +// useUserAssetsTestStore.getState().setAddress(testAddresses[1]); +// break; +// case testAddresses[1]: +// useUserAssetsTestStore.getState().setAddress(testAddresses[2]); +// break; +// case testAddresses[2]: +// useUserAssetsTestStore.getState().setAddress(testAddresses[0]); +// break; +// } +// }} +// style={styles.button} +// > +// +// Shuffle Address +// +// +// { +// useUserAssetsTestStore.setState({ enabled: !enabled }); +// }} +// style={styles.button} +// > +// +// {useUserAssetsTestStore.getState().enabled ? 'Disable Fetching' : 'Enable Fetching'} +// +// +// +// +// ); +// }); + +// if (ENABLE_LOGS) console.log('[πŸ’Ύ UserAssetsTest πŸ’Ύ] Initial data exists:', !!useUserAssetsTestStore.getState().getData()); + +// // eslint-disable-next-line @typescript-eslint/no-unused-vars +// function logFetchInfo(params: UserAssetsQueryParams) { +// const formattedTimeWithSeconds = new Date(Date.now()).toLocaleTimeString('en-US', { +// hour: '2-digit', +// minute: '2-digit', +// second: '2-digit', +// }); +// console.log('[πŸ”„ UserAssetsTest - logFetchInfo πŸ”„]', '\nTime:', formattedTimeWithSeconds, '\nParams:', { +// address: params.address, +// currency: params.currency, +// raw: JSON.stringify(Object.values(params), null, 2), +// }); +// } + +// function getFirstFiveCoinIconUrls(data: ParsedAssetsDictByChain) { +// const result: string[] = []; +// outer: for (const chainAssets of Object.values(data)) { +// for (const token of Object.values(chainAssets)) { +// if (token.icon_url) { +// result.push(token.icon_url); +// if (result.length === 5) { +// break outer; +// } +// } +// } +// } +// return result; +// } + +// type FetchUserAssetsArgs = { +// address: Address | string; +// currency: SupportedCurrencyKey; +// testnetMode?: boolean; +// }; + +// export async function simpleUserAssetsQuery({ address, currency }: FetchUserAssetsArgs): Promise { +// if (!address) return {}; +// try { +// const url = `/${useBackendNetworksStore.getState().getSupportedChainIds().join(',')}/${address}/assets?currency=${currency.toLowerCase()}`; +// const res = await addysHttp.get(url, { +// timeout: time.seconds(20), +// }); +// const chainIdsInResponse = res?.data?.meta?.chain_ids || []; +// const assets = res?.data?.payload?.assets?.filter(asset => !asset.asset.defi_position) || []; + +// if (assets.length && chainIdsInResponse.length) { +// return parseUserAssets({ +// assets, +// chainIds: chainIdsInResponse, +// currency, +// }); +// } +// return {}; +// } catch (e) { +// logger.error(new RainbowError('[simpleUserAssetsQuery]: Failed to fetch user assets'), { +// message: (e as Error)?.message, +// }); +// return {}; +// } +// } + +// const styles = StyleSheet.create({ +// button: { +// alignItems: 'center', +// backgroundColor: 'blue', +// borderRadius: 22, +// height: 44, +// justifyContent: 'center', +// paddingHorizontal: 20, +// }, +// buttonGroup: { +// alignItems: 'center', +// flexDirection: 'column', +// gap: 24, +// justifyContent: 'center', +// }, +// coinIcon: { +// borderRadius: 16, +// height: 32, +// width: 32, +// }, +// coinIconContainer: { +// flexDirection: 'row', +// gap: 12, +// }, +// container: { +// alignItems: 'center', +// flex: 1, +// flexDirection: 'column', +// gap: 32, +// justifyContent: 'center', +// }, +// }); diff --git a/src/utils/index.ts b/src/utils/index.ts index aad92e7e346..2d9d56d92c6 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -36,3 +36,4 @@ export { default as withSpeed } from './withSpeed'; export { default as FallbackIcon } from './CoinIcons/FallbackIcon'; export { default as getExchangeIconUrl } from './getExchangeIconUrl'; export { resolveFirstRejectLast } from './resolveFirstRejectLast'; +export { time } from './time'; diff --git a/src/utils/time.ts b/src/utils/time.ts new file mode 100644 index 00000000000..cbf59b0e2fd --- /dev/null +++ b/src/utils/time.ts @@ -0,0 +1,40 @@ +type TimeInMs = number; +type TimeUtils = { + /** Convert seconds to milliseconds */ + seconds: (seconds: number) => TimeInMs; + /** Convert minutes to milliseconds */ + minutes: (minutes: number) => TimeInMs; + /** Convert hours to milliseconds */ + hours: (hours: number) => TimeInMs; + /** Convert days to milliseconds */ + days: (days: number) => TimeInMs; + /** Convert weeks to milliseconds */ + weeks: (weeks: number) => TimeInMs; + /** Represents infinite time */ + infinity: typeof Infinity; + /** Represents zero time */ + zero: 0; +}; + +/** + * Utility object for time conversions and helpers. + * All methods convert the input unit to milliseconds. + * @example + * time.seconds(5) // 5 seconds + * time.minutes(2) // 2 minutes + * time.hours(1) // 1 hour + * time.days(5) // 5 days + * time.weeks(2) // 2 weeks + * –– + * time.infinity // Infinity + * time.zero // 0 + */ +export const time: TimeUtils = { + seconds: seconds => seconds * 1000, + minutes: minutes => time.seconds(minutes * 60), + hours: hours => time.minutes(hours * 60), + days: days => time.hours(days * 24), + weeks: weeks => time.days(weeks * 7), + infinity: Infinity, + zero: 0, +}; diff --git a/yarn.lock b/yarn.lock index 72000dab019..beac58e432c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8868,7 +8868,7 @@ __metadata: querystring-es3: "npm:0.2.1" react: "npm:18.2.0" react-coin-icon: "rainbow-me/react-coin-icon#06464588a3d986f6ef3a7d7341b2d7ea0c5ac50b" - react-fast-compare: "npm:2.0.4" + react-fast-compare: "npm:3.2.2" react-fast-memo: "npm:2.0.1" react-flatten-children: "npm:1.1.2" react-freeze: "npm:1.0.4" @@ -22105,10 +22105,10 @@ __metadata: languageName: node linkType: hard -"react-fast-compare@npm:2.0.4": - version: 2.0.4 - resolution: "react-fast-compare@npm:2.0.4" - checksum: 10c0/f0300c677e95198b5f993cbb8a983dab09586157dc678f9e2b5b29ff941b6677a8776fbbdc425ce102fad86937e36bb45cfcfd797f006270b97ccf287ebfb885 +"react-fast-compare@npm:3.2.2": + version: 3.2.2 + resolution: "react-fast-compare@npm:3.2.2" + checksum: 10c0/0bbd2f3eb41ab2ff7380daaa55105db698d965c396df73e6874831dbafec8c4b5b08ba36ff09df01526caa3c61595247e3269558c284e37646241cba2b90a367 languageName: node linkType: hard