diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index faf771f4d4..3e574d8bb6 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -3,8 +3,8 @@ import 'cypress-hardhat/lib/browser' import { Eip1193Bridge } from '@ethersproject/experimental/lib/eip1193-bridge' import { FeatureFlag } from '../../src/featureFlags' -import { UserState } from '../../src/state/user/reducer' -import { CONNECTED_WALLET_USER_STATE } from '../utils/user-state' +import { initialState, UserState } from '../../src/state/user/reducer' +import { CONNECTED_WALLET_USER_STATE, setInitialUserState } from '../utils/user-state' declare global { // eslint-disable-next-line @typescript-eslint/no-namespace @@ -54,18 +54,12 @@ Cypress.Commands.overwrite( onBeforeLoad(win) { options?.onBeforeLoad?.(win) - // We want to test from a clean state, so we clear the local storage (which clears redux). - win.localStorage.clear() - - // Set initial user state. - win.localStorage.setItem( - 'redux_localstorage_simple_user', // storage key for the user reducer using 'redux-localstorage-simple' - JSON.stringify({ - hideUniswapWalletBanner: true, - ...CONNECTED_WALLET_USER_STATE, - ...(options?.userState ?? {}), - }) - ) + setInitialUserState(win, { + ...initialState, + hideUniswapWalletBanner: true, + ...CONNECTED_WALLET_USER_STATE, + ...(options?.userState ?? {}), + }) // Set feature flags, if configured. if (options?.featureFlags) { diff --git a/cypress/utils/user-state.ts b/cypress/utils/user-state.ts index 8c6440a6d7..1389f47d4b 100644 --- a/cypress/utils/user-state.ts +++ b/cypress/utils/user-state.ts @@ -4,3 +4,31 @@ import { UserState } from '../../src/state/user/reducer' export const CONNECTED_WALLET_USER_STATE: Partial = { selectedWallet: ConnectionType.INJECTED } export const DISCONNECTED_WALLET_USER_STATE: Partial = { selectedWallet: undefined } + +/** + * This sets the initial value of the "user" slice in IndexedDB. + * Other persisted slices are not set, so they will be filled with their respective initial values + * when the app runs. + */ +export function setInitialUserState(win: Cypress.AUTWindow, initialUserState: any) { + win.indexedDB.deleteDatabase('redux') + + const dbRequest = win.indexedDB.open('redux') + + dbRequest.onsuccess = function () { + const db = dbRequest.result + const transaction = db.transaction('keyvaluepairs', 'readwrite') + const store = transaction.objectStore('keyvaluepairs') + store.put( + { + user: initialUserState, + }, + 'persist:interface' + ) + } + + dbRequest.onupgradeneeded = function () { + const db = dbRequest.result + db.createObjectStore('keyvaluepairs') + } +} diff --git a/package.json b/package.json index 89abd4a854..e91ef53798 100644 --- a/package.json +++ b/package.json @@ -143,6 +143,7 @@ "terser-webpack-plugin": "^5.3.9", "ts-jest": "^29.1.1", "ts-transform-graphql-tag": "^0.2.1", + "tsafe": "^1.6.4", "typechain": "^5.0.0", "typescript": "^4.9.4", "webpack": "^5.88.2", @@ -235,6 +236,7 @@ "inter-ui": "^3.13.1", "jotai": "^1.3.7", "jsbi": "^3.1.4", + "localforage": "^1.10.0", "make-plural": "^7.0.0", "ms": "^2.1.3", "multicodec": "^3.0.1", @@ -265,7 +267,7 @@ "react-window-infinite-loader": "^1.0.8", "rebass": "^4.0.7", "redux": "^4.1.2", - "redux-localstorage-simple": "^2.3.1", + "redux-persist": "^6.0.0", "statsig-react": "^1.22.0", "styled-components": "^5.3.5", "tiny-invariant": "^1.2.0", diff --git a/src/hooks/useEagerlyConnect.ts b/src/hooks/useEagerlyConnect.ts index 173c126ece..07bae94486 100644 --- a/src/hooks/useEagerlyConnect.ts +++ b/src/hooks/useEagerlyConnect.ts @@ -1,7 +1,6 @@ import { Connector } from '@web3-react/types' import { gnosisSafeConnection, networkConnection } from 'connection' import { getConnection } from 'connection' -import { Connection } from 'connection/types' import { useEffect } from 'react' import { useAppDispatch, useAppSelector } from 'state/hooks' import { updateSelectedWallet } from 'state/user/reducer' @@ -22,22 +21,24 @@ export default function useEagerlyConnect() { const dispatch = useAppDispatch() const selectedWallet = useAppSelector((state) => state.user.selectedWallet) - - let selectedConnection: Connection | undefined - if (selectedWallet) { - try { - selectedConnection = getConnection(selectedWallet) - } catch { - dispatch(updateSelectedWallet({ wallet: undefined })) - } - } + const rehydrated = useAppSelector((state) => state._persist.rehydrated) useEffect(() => { - connect(gnosisSafeConnection.connector) - connect(networkConnection.connector) + if (!selectedWallet) return + try { + const selectedConnection = getConnection(selectedWallet) + connect(gnosisSafeConnection.connector) + connect(networkConnection.connector) - if (selectedConnection) { - connect(selectedConnection.connector) - } // The dependency list is empty so this is only run once on mount - }, []) // eslint-disable-line react-hooks/exhaustive-deps + if (selectedConnection) { + connect(selectedConnection.connector) + } + } catch { + // only clear the persisted wallet type if it failed to connect. + if (rehydrated) { + dispatch(updateSelectedWallet({ wallet: undefined })) + } + return + } + }, [dispatch, rehydrated, selectedWallet]) } diff --git a/src/state/application/hooks.ts b/src/state/application/hooks.ts index 8504f8216e..7a923e6709 100644 --- a/src/state/application/hooks.ts +++ b/src/state/application/hooks.ts @@ -3,8 +3,8 @@ import { sendAnalyticsEvent } from 'analytics' import { DEFAULT_TXN_DISMISS_MS } from 'constants/misc' import { useCallback, useEffect, useMemo, useState } from 'react' import { useAppDispatch, useAppSelector } from 'state/hooks' +import { AppState } from 'state/reducer' -import { AppState } from '../types' import { addPopup, ApplicationModal, diff --git a/src/state/application/reducer.ts b/src/state/application/reducer.ts index e11ee5ef0a..b2dac95132 100644 --- a/src/state/application/reducer.ts +++ b/src/state/application/reducer.ts @@ -47,7 +47,7 @@ export enum ApplicationModal { UNISWAP_NFT_AIRDROP_CLAIM, } -type PopupList = Array<{ key: string; show: boolean; content: PopupContent; removeAfterMs: number | null }> +export type PopupList = Array<{ key: string; show: boolean; content: PopupContent; removeAfterMs: number | null }> export interface ApplicationState { readonly chainId: number | null diff --git a/src/state/burn/hooks.tsx b/src/state/burn/hooks.tsx index a92a2df86d..2bfd2850d2 100644 --- a/src/state/burn/hooks.tsx +++ b/src/state/burn/hooks.tsx @@ -6,11 +6,11 @@ import JSBI from 'jsbi' import tryParseCurrencyAmount from 'lib/utils/tryParseCurrencyAmount' import { ReactNode, useCallback } from 'react' import { useAppDispatch, useAppSelector } from 'state/hooks' +import { AppState } from 'state/reducer' import { useTotalSupply } from '../../hooks/useTotalSupply' import { useV2Pair } from '../../hooks/useV2Pairs' import { useTokenBalances } from '../connection/hooks' -import { AppState } from '../types' import { Field, typeInput } from './actions' export function useBurnState(): AppState['burn'] { diff --git a/src/state/burn/reducer.ts b/src/state/burn/reducer.ts index a4bdbf35f4..188ff4e46b 100644 --- a/src/state/burn/reducer.ts +++ b/src/state/burn/reducer.ts @@ -2,7 +2,7 @@ import { createReducer } from '@reduxjs/toolkit' import { Field, typeInput } from './actions' -interface BurnState { +export interface BurnState { readonly independentField: Field readonly typedValue: string } diff --git a/src/state/burn/v3/hooks.tsx b/src/state/burn/v3/hooks.tsx index b6ff8bd971..5270998536 100644 --- a/src/state/burn/v3/hooks.tsx +++ b/src/state/burn/v3/hooks.tsx @@ -10,7 +10,7 @@ import { useAppDispatch, useAppSelector } from 'state/hooks' import { PositionDetails } from 'types/position' import { unwrappedToken } from 'utils/unwrappedToken' -import { AppState } from '../../types' +import { AppState } from '../../reducer' import { selectPercent } from './actions' export function useBurnV3State(): AppState['burnV3'] { diff --git a/src/state/burn/v3/reducer.ts b/src/state/burn/v3/reducer.ts index d051cdf6a2..9beea7ab75 100644 --- a/src/state/burn/v3/reducer.ts +++ b/src/state/burn/v3/reducer.ts @@ -2,7 +2,7 @@ import { createReducer } from '@reduxjs/toolkit' import { selectPercent } from './actions' -interface BurnV3State { +export interface BurnV3State { readonly percent: number } diff --git a/src/state/index.ts b/src/state/index.ts index c7438b37fd..9b2c2f7784 100644 --- a/src/state/index.ts +++ b/src/state/index.ts @@ -1,35 +1,41 @@ import { configureStore } from '@reduxjs/toolkit' import { setupListeners } from '@reduxjs/toolkit/query/react' -import { load, save } from 'redux-localstorage-simple' -import { isTestEnv } from 'utils/env' +import { persistStore } from 'redux-persist' import { updateVersion } from './global/actions' import { sentryEnhancer } from './logging' import reducer from './reducer' import { routingApi } from './routing/slice' -const PERSISTED_KEYS: string[] = ['user', 'transactions', 'signatures', 'lists'] +export function createDefaultStore() { + return configureStore({ + reducer, + enhancers: (defaultEnhancers) => defaultEnhancers.concat(sentryEnhancer), + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ + thunk: true, + serializableCheck: { + // meta.arg and meta.baseQueryMeta are defaults. payload.trade is a nonserializable return value, but that's ok + // because we are not adding it into any persisted store that requires serialization (e.g. localStorage) + ignoredActionPaths: ['meta.arg', 'meta.baseQueryMeta', 'payload.trade'], + ignoredPaths: [routingApi.reducerPath], + ignoredActions: [ + // ignore the redux-persist actions + 'persist/PERSIST', + 'persist/REHYDRATE', + 'persist/PURGE', + 'persist/FLUSH', + ], + }, + }).concat(routingApi.middleware), + }) +} -const store = configureStore({ - reducer, - enhancers: (defaultEnhancers) => defaultEnhancers.concat(sentryEnhancer), - middleware: (getDefaultMiddleware) => - getDefaultMiddleware({ - thunk: true, - serializableCheck: { - // meta.arg and meta.baseQueryMeta are defaults. payload.trade is a nonserializable return value, but that's ok - // because we are not adding it into any persisted store that requires serialization (e.g. localStorage) - ignoredActionPaths: ['meta.arg', 'meta.baseQueryMeta', 'payload.trade'], - ignoredPaths: [routingApi.reducerPath], - }, - }) - .concat(routingApi.middleware) - .concat(save({ states: PERSISTED_KEYS, debounce: 1000 })), - preloadedState: load({ states: PERSISTED_KEYS, disableWarnings: isTestEnv() }), -}) - -store.dispatch(updateVersion()) +const store = createDefaultStore() +export const persistor = persistStore(store) setupListeners(store.dispatch) +store.dispatch(updateVersion()) + export default store diff --git a/src/state/lists/hooks.ts b/src/state/lists/hooks.ts index a78eb0d45f..11e9411af0 100644 --- a/src/state/lists/hooks.ts +++ b/src/state/lists/hooks.ts @@ -1,10 +1,10 @@ import { TokenAddressMap, tokensToChainTokenMap } from 'lib/hooks/useTokenList/utils' import { useMemo } from 'react' import { useAppSelector } from 'state/hooks' +import { AppState } from 'state/reducer' import sortByListPriority from 'utils/listSort' import BROKEN_LIST from '../../constants/tokenLists/broken.tokenlist.json' -import { AppState } from '../types' import { DEFAULT_ACTIVE_LIST_URLS, UNSUPPORTED_LIST_URLS } from './../../constants/lists' type Mutable = { diff --git a/src/state/lists/reducer.test.ts b/src/state/lists/reducer.test.ts index 9faa9a7fcf..1bd11e72d4 100644 --- a/src/state/lists/reducer.test.ts +++ b/src/state/lists/reducer.test.ts @@ -1,8 +1,8 @@ +import tokenSafetyLookup from 'constants/tokenSafetyLookup' import { createStore, Store } from 'redux' +import { updateVersion } from 'state/global/actions' import { DEFAULT_LIST_OF_LISTS } from '../../constants/lists' -import tokenSafetyLookup from '../../constants/tokenSafetyLookup' -import { updateVersion } from '../global/actions' import { acceptListUpdate, addList, fetchTokenList, removeList } from './actions' import reducer, { ListsState } from './reducer' diff --git a/src/state/lists/reducer.ts b/src/state/lists/reducer.ts index 33254a3b00..6669f472c3 100644 --- a/src/state/lists/reducer.ts +++ b/src/state/lists/reducer.ts @@ -30,7 +30,7 @@ const NEW_LIST_STATE: ListState = { type Mutable = { -readonly [P in keyof T]: T[P] extends ReadonlyArray ? U[] : T[P] } -const initialState: ListsState = { +export const initialState: ListsState = { lastInitializedDefaultListOfLists: DEFAULT_LIST_OF_LISTS, byUrl: { ...DEFAULT_LIST_OF_LISTS.reduce>((memo, listUrl) => { diff --git a/src/state/logging.ts b/src/state/logging.ts index 644d22f29f..93d01352f3 100644 --- a/src/state/logging.ts +++ b/src/state/logging.ts @@ -1,7 +1,7 @@ import * as Sentry from '@sentry/react' import noop from 'utils/noop' -import { AppState } from './types' +import { AppState } from './reducer' /* Utility type to mark all properties of a type as optional */ type DeepPartial = T extends object diff --git a/src/state/logs/slice.ts b/src/state/logs/slice.ts index 37fb37837b..f4168ace0f 100644 --- a/src/state/logs/slice.ts +++ b/src/state/logs/slice.ts @@ -3,7 +3,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { filterToKey, Log } from './utils' -interface LogsState { +export interface LogsState { [chainId: number]: { [filterKey: string]: { listeners: number diff --git a/src/state/migrations.test.ts b/src/state/migrations.test.ts new file mode 100644 index 0000000000..a23723e183 --- /dev/null +++ b/src/state/migrations.test.ts @@ -0,0 +1,135 @@ +import { Store } from '@reduxjs/toolkit' +import { persistStore } from 'redux-persist' +import { createDefaultStore } from 'state' + +import { initialState as initialListsState } from './lists/reducer' +import { initialState as initialSignaturesState } from './signatures/reducer' +import { initialState as initialTransactionsState } from './transactions/reducer' +import { initialState as initialUserState } from './user/reducer' + +const defaultState = { + lists: {}, + transactions: {}, + user: {}, + _persist: { + rehydrated: true, + version: 0, + }, + application: { + chainId: null, + fiatOnramp: { + availabilityChecked: false, + available: false, + }, + openModal: null, + popupList: [], + }, + burn: { + independentField: 'LIQUIDITY_PERCENT', + typedValue: '0', + }, + burnV3: { + percent: 0, + }, + logs: {}, + mint: { + independentField: 'CURRENCY_A', + leftRangeTypedValue: '', + otherTypedValue: '', + rightRangeTypedValue: '', + startPriceTypedValue: '', + typedValue: '', + }, + mintV3: { + independentField: 'CURRENCY_A', + leftRangeTypedValue: '', + rightRangeTypedValue: '', + startPriceTypedValue: '', + typedValue: '', + }, + multicall: { + callResults: {}, + }, + wallets: { + connectedWallets: [], + switchingChain: false, + }, +} + +describe('redux migrations', () => { + let store: Store + + beforeEach(() => { + localStorage.clear() + // Re-create the store before each test so it starts with undefined state. + store = createDefaultStore() + }) + + it('clears legacy redux_localstorage_simple values during the initial migration', async () => { + localStorage.setItem( + 'redux_localstorage_simple_transactions', + JSON.stringify({ 1: { test: { info: 'transactions' } } }) + ) + localStorage.setItem('redux_localstorage_simple_user', JSON.stringify({ test: 'user' })) + localStorage.setItem('redux_localstorage_simple_lists', JSON.stringify({ test: 'lists' })) + localStorage.setItem('redux_localstorage_simple_signatures', JSON.stringify({ test: 'signatures' })) + + persistStore(store) + // wait for the migration to complete + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(localStorage.getItem('redux_localstorage_simple_transactions')).toBeNull() + expect(localStorage.getItem('redux_localstorage_simple_user')).toBeNull() + expect(localStorage.getItem('redux_localstorage_simple_lists')).toBeNull() + expect(localStorage.getItem('redux_localstorage_simple_signatures')).toBeNull() + + const state = store.getState() + expect(state).toMatchObject({ + ...defaultState, + // These are migrated values. + lists: { + test: 'lists', + }, + transactions: { + 1: { + test: { info: 'transactions' }, + }, + }, + user: { + test: 'user', + }, + signatures: { + test: 'signatures', + }, + }) + }) + + it('initial state with no previous persisted state', async () => { + persistStore(store) + // wait for the migration to complete + await new Promise((resolve) => setTimeout(resolve, 0)) + + const state = store.getState() + expect(state).toMatchObject(defaultState) + }) + + it('migrates from a previous version of the state type', async () => { + localStorage.setItem( + 'persist:interface', + JSON.stringify({ + user: { ...initialUserState, test: 'user' }, + transactions: initialTransactionsState, + lists: initialListsState, + signatures: initialSignaturesState, + _persist: { version: -1 }, + }) + ) + + persistStore(store) + // wait for the migration to complete + await new Promise((resolve) => setTimeout(resolve, 0)) + + const state = store.getState() + expect(state).toMatchObject(defaultState) + }) +}) diff --git a/src/state/migrations.ts b/src/state/migrations.ts new file mode 100644 index 0000000000..f7d78e46a1 --- /dev/null +++ b/src/state/migrations.ts @@ -0,0 +1,35 @@ +import { createMigrate, MigrationManifest, PersistedState, PersistMigrate } from 'redux-persist' +import { MigrationConfig } from 'redux-persist/es/createMigrate' + +import { migration0 } from './migrations/0' +import { legacyLocalStorageMigration } from './migrations/legacy' + +/** + * These run once per state re-hydration when a version mismatch is detected. + * Keep them as lightweight as possible. + * + * Migration functions should not assume that any value exists in localStorage previously, + * because a user may be visiting the site for the first time or have cleared their localStorage. + */ + +// The target version number is the key +export const migrations: MigrationManifest = { + 0: migration0, +} + +// We use a custom migration function for the initial state, because redux-persist +// skips migration if there is no initial state, but we want to migrate +// previous persisted state from redux-localstorage-simple. +export function customCreateMigrate(migrations: MigrationManifest, options: MigrationConfig): PersistMigrate { + const defaultMigrate = createMigrate(migrations, options) + + return async (state: PersistedState, currentVersion: number) => { + if (state === undefined) { + // If no state exists, run the legacy migration to set initial state + state = await legacyLocalStorageMigration() + } + + // Otherwise, use the default migration process + return defaultMigrate(state, currentVersion) + } +} diff --git a/src/state/migrations/0.ts b/src/state/migrations/0.ts new file mode 100644 index 0000000000..8495266874 --- /dev/null +++ b/src/state/migrations/0.ts @@ -0,0 +1,10 @@ +import { PersistedState } from 'redux-persist' + +/** + * Initial migration as a proof of concept. + * + * Legacy migration from redux-localstorage-simple happens in legacy.ts + */ +export const migration0 = (state: PersistedState) => { + return state +} diff --git a/src/state/migrations/legacy.ts b/src/state/migrations/legacy.ts new file mode 100644 index 0000000000..3ab592064d --- /dev/null +++ b/src/state/migrations/legacy.ts @@ -0,0 +1,128 @@ +import { DEFAULT_DEADLINE_FROM_NOW } from 'constants/misc' +import { persistor } from 'state' + +import { initialState as initialListsState } from '../lists/reducer' +import { RouterPreference } from '../routing/types' +import { TransactionState } from '../transactions/reducer' +import { initialState as initialTransactionsState } from '../transactions/reducer' +import { UserState } from '../user/reducer' +import { initialState as initialUserState } from '../user/reducer' +import { SlippageTolerance } from '../user/types' + +const currentTimestamp = () => new Date().getTime() + +function tryParseOldState(value: string | null, fallback: T): T { + try { + return value ? JSON.parse(value) : fallback + } catch (e) { + return fallback + } +} + +/** + * These functions handle all migrations that existed before we started tracking version numbers. + */ + +export const legacyLocalStorageMigration = async () => { + const oldTransactions = localStorage.getItem('redux_localstorage_simple_transactions') + const oldUser = localStorage.getItem('redux_localstorage_simple_user') + const oldLists = localStorage.getItem('redux_localstorage_simple_lists') + const oldSignatures = localStorage.getItem('redux_localstorage_simple_signatures') + + const newTransactions = tryParseOldState(oldTransactions, initialTransactionsState) + const newUser = tryParseOldState(oldUser, initialUserState) + const newLists = tryParseOldState(oldLists, initialListsState) + const newSignatures = tryParseOldState(oldSignatures, {}) + + const result = { + user: legacyUserMigrations(newUser), + transactions: legacyTransactionMigrations(newTransactions), + lists: newLists, + signatures: newSignatures, + _persist: { version: 0, rehydrated: true }, + } + + await persistor.flush() + + localStorage.removeItem('redux_localstorage_simple_transactions') + localStorage.removeItem('redux_localstorage_simple_user') + localStorage.removeItem('redux_localstorage_simple_lists') + localStorage.removeItem('redux_localstorage_simple_signatures') + return result +} + +function legacyTransactionMigrations(state: any): TransactionState { + // Make a copy of the object so we can mutate it. + const result = JSON.parse(JSON.stringify(state)) + // in case there are any transactions in the store with the old format, remove them + Object.keys(result).forEach((chainId) => { + const chainTransactions = result[chainId as unknown as number] + Object.keys(chainTransactions).forEach((hash) => { + if (!('info' in chainTransactions[hash])) { + // clear old transactions that don't have the right format + delete chainTransactions[hash] + } + }) + }) + return result +} + +function legacyUserMigrations(state: any): UserState { + // Make a copy of the object so we can mutate it. + const result = JSON.parse(JSON.stringify(state)) + // If `selectedWallet` is a WalletConnect v1 wallet, reset to default. + if (result.selectedWallet) { + const selectedWallet = result.selectedWallet as string + if (selectedWallet === 'UNIWALLET' || selectedWallet === 'UNISWAP_WALLET' || selectedWallet === 'WALLET_CONNECT') { + delete result.selectedWallet + } + } + + // If `userSlippageTolerance` is not present or its value is invalid, reset to default + if ( + typeof result.userSlippageTolerance !== 'number' || + !Number.isInteger(result.userSlippageTolerance) || + result.userSlippageTolerance < 0 || + result.userSlippageTolerance > 5000 + ) { + result.userSlippageTolerance = SlippageTolerance.Auto + } else { + if ( + !result.userSlippageToleranceHasBeenMigratedToAuto && + [10, 50, 100].indexOf(result.userSlippageTolerance) !== -1 + ) { + result.userSlippageTolerance = SlippageTolerance.Auto + result.userSlippageToleranceHasBeenMigratedToAuto = true + } + } + + // If `userDeadline` is not present or its value is invalid, reset to default + if ( + typeof result.userDeadline !== 'number' || + !Number.isInteger(result.userDeadline) || + result.userDeadline < 60 || + result.userDeadline > 180 * 60 + ) { + result.userDeadline = DEFAULT_DEADLINE_FROM_NOW + } + + // If `userRouterPreference` is not present, reset to default + if (typeof result.userRouterPreference !== 'string') { + result.userRouterPreference = RouterPreference.API + } + + // If `userRouterPreference` is `AUTO`, migrate to `API` + if ((result.userRouterPreference as string) === 'auto') { + result.userRouterPreference = RouterPreference.API + } + + //If `buyFiatFlowCompleted` is present, delete it using filtering + if ('buyFiatFlowCompleted' in result) { + //ignoring due to type errors occuring since we now remove this state + //@ts-ignore + delete result.buyFiatFlowCompleted + } + + result.lastUpdateVersionTimestamp = currentTimestamp() + return result +} diff --git a/src/state/mint/hooks.tsx b/src/state/mint/hooks.tsx index ec6d9c3b3b..476413f971 100644 --- a/src/state/mint/hooks.tsx +++ b/src/state/mint/hooks.tsx @@ -10,7 +10,7 @@ import { useAppDispatch, useAppSelector } from 'state/hooks' import { useTotalSupply } from '../../hooks/useTotalSupply' import { PairState, useV2Pair } from '../../hooks/useV2Pairs' import { useCurrencyBalances } from '../connection/hooks' -import { AppState } from '../types' +import { AppState } from '../reducer' import { Field, typeInput } from './actions' const ZERO = JSBI.BigInt(0) diff --git a/src/state/mint/v3/hooks.tsx b/src/state/mint/v3/hooks.tsx index 79e263c796..f2184dd565 100644 --- a/src/state/mint/v3/hooks.tsx +++ b/src/state/mint/v3/hooks.tsx @@ -23,7 +23,7 @@ import { getTickToPrice } from 'utils/getTickToPrice' import { BIG_INT_ZERO } from '../../../constants/misc' import { PoolState } from '../../../hooks/usePools' import { useCurrencyBalances } from '../../connection/hooks' -import { AppState } from '../../types' +import { AppState } from '../../reducer' import { Bound, Field, diff --git a/src/state/mint/v3/reducer.ts b/src/state/mint/v3/reducer.ts index 8254650beb..41842ff4c3 100644 --- a/src/state/mint/v3/reducer.ts +++ b/src/state/mint/v3/reducer.ts @@ -10,9 +10,9 @@ import { typeStartPriceInput, } from './actions' -type FullRange = true +export type FullRange = true -interface MintState { +export interface MintState { readonly independentField: Field readonly typedValue: string readonly startPriceTypedValue: string // for the case when there's no liquidity diff --git a/src/state/reducer.ts b/src/state/reducer.ts index e55bab2449..e83e8485a8 100644 --- a/src/state/reducer.ts +++ b/src/state/reducer.ts @@ -1,10 +1,15 @@ +import { combineReducers } from '@reduxjs/toolkit' import multicall from 'lib/state/multicall' +import localForage from 'localforage' +import { PersistConfig, persistReducer } from 'redux-persist' +import { isDevelopmentEnv } from 'utils/env' import application from './application/reducer' import burn from './burn/reducer' import burnV3 from './burn/v3/reducer' import lists from './lists/reducer' import logs from './logs/slice' +import { customCreateMigrate, migrations } from './migrations' import mint from './mint/reducer' import mintV3 from './mint/v3/reducer' import { routingApi } from './routing/slice' @@ -13,18 +18,45 @@ import transactions from './transactions/reducer' import user from './user/reducer' import wallets from './wallets/reducer' -export default { - application, +const persistedReducers = { user, transactions, signatures, + lists, +} + +const appReducer = combineReducers({ + application, wallets, mint, mintV3, burn, burnV3, multicall: multicall.reducer, - lists, logs, [routingApi.reducerPath]: routingApi.reducer, + ...persistedReducers, +}) + +export type AppState = ReturnType + +const persistConfig: PersistConfig = { + key: 'interface', + version: 0, // see migrations.ts for more details about this version + storage: localForage.createInstance({ + name: 'redux', + }), + migrate: customCreateMigrate(migrations, { debug: false }), + whitelist: Object.keys(persistedReducers), + throttle: 1000, // ms + serialize: false, + // The typescript definitions are wrong - we need this to be false for unserialized storage to work. + // We need unserialized storage for inspectable db entries for debugging. + // @ts-ignore + deserialize: false, + debug: isDevelopmentEnv(), } + +const persistedReducer = persistReducer(persistConfig, appReducer) + +export default persistedReducer diff --git a/src/state/reducerTypeTest.ts b/src/state/reducerTypeTest.ts new file mode 100644 index 0000000000..5db47946ae --- /dev/null +++ b/src/state/reducerTypeTest.ts @@ -0,0 +1,188 @@ +import { ChainId } from '@uniswap/sdk-core' +import { TokenList } from '@uniswap/token-lists' +import { ConnectionType } from 'connection/types' +import { SupportedLocale } from 'constants/locales' +import multicall from 'lib/state/multicall' +import { CombinedState } from 'redux' +import { assert, Equals } from 'tsafe' + +import { ApplicationModal, ApplicationState, PopupList } from './application/reducer' +import { Field as BurnField } from './burn/actions' +import { BurnState } from './burn/reducer' +import { BurnV3State } from './burn/v3/reducer' +import { ListsState } from './lists/reducer' +import { LogsState } from './logs/slice' +import { Log } from './logs/utils' +import { Field } from './mint/actions' +import { MintState } from './mint/reducer' +import { Field as FieldV3 } from './mint/v3/actions' +import { FullRange, MintState as MintV3State } from './mint/v3/reducer' +import { AppState } from './reducer' +import { routingApi } from './routing/slice' +import { RouterPreference } from './routing/types' +import { SignatureState } from './signatures/reducer' +import { TransactionState } from './transactions/reducer' +import { TransactionDetails } from './transactions/types' +import { UserState } from './user/reducer' +import { SerializedPair, SerializedToken, SlippageTolerance } from './user/types' +import { WalletState } from './wallets/reducer' +import { Wallet } from './wallets/types' + +/** + * WARNING: + * Any changes made to the types of the Redux store could potentially require a migration. + * + * If you're making a change that alters the structure or types of the Redux state, + * consider whether existing state stored in users' browsers will still be compatible + * with the new types. + * + * If compatibility could be broken, you may need to create a migration + * function that can convert the existing state into a format that's compatible with + * the new types, or otherwise adjust the user's persisted state in some way + * to prevent undesirable behavior. + * + * This migration function should be added to the `migrations` object + * in our Redux store configuration. + * + * If no migration is needed, just update the expected types here to fix the typecheck. + */ + +type ExpectedAppState = CombinedState<{ + user: UserState + transactions: TransactionState + signatures: SignatureState + lists: ListsState + application: ApplicationState + wallets: WalletState + mint: MintState + mintV3: MintV3State + burn: BurnState + burnV3: BurnV3State + multicall: ReturnType + logs: LogsState + [routingApi.reducerPath]: ReturnType +}> + +assert>() + +interface ExpectedUserState { + selectedWallet?: ConnectionType + lastUpdateVersionTimestamp?: number + userLocale: SupportedLocale | null + userRouterPreference: RouterPreference + userHideClosedPositions: boolean + userSlippageTolerance: number | SlippageTolerance.Auto + userSlippageToleranceHasBeenMigratedToAuto: boolean + userDeadline: number + tokens: { + [chainId: number]: { + [address: string]: SerializedToken + } + } + pairs: { + [chainId: number]: { + [key: string]: SerializedPair + } + } + timestamp: number + URLWarningVisible: boolean + hideBaseWalletBanner: boolean + showSurveyPopup?: boolean + disabledUniswapX?: boolean +} + +assert>() + +interface ExpectedTransactionState { + [chainId: number]: { + [txHash: string]: TransactionDetails + } +} + +assert>() + +interface ExpectedListsState { + readonly byUrl: { + readonly [url: string]: { + readonly current: TokenList | null + readonly pendingUpdate: TokenList | null + readonly loadingRequestId: string | null + readonly error: string | null + } + } + readonly lastInitializedDefaultListOfLists?: string[] +} + +assert>() + +interface ExpectedApplicationState { + readonly chainId: number | null + readonly fiatOnramp: { available: boolean; availabilityChecked: boolean } + readonly openModal: ApplicationModal | null + readonly popupList: PopupList +} + +assert>() + +interface ExpectedWalletState { + connectedWallets: Wallet[] + switchingChain: ChainId | false +} + +assert>() + +interface ExpectedMintState { + readonly independentField: Field + readonly typedValue: string + readonly otherTypedValue: string + readonly startPriceTypedValue: string + readonly leftRangeTypedValue: string + readonly rightRangeTypedValue: string +} + +assert>() + +interface ExpectedMintV3State { + readonly independentField: FieldV3 + readonly typedValue: string + readonly startPriceTypedValue: string + readonly leftRangeTypedValue: string | FullRange + readonly rightRangeTypedValue: string | FullRange +} + +assert>() + +interface ExpectedBurnState { + readonly independentField: BurnField + readonly typedValue: string +} + +assert>() + +interface ExpectedBurnV3State { + readonly percent: number +} + +assert>() + +interface ExpectedLogsState { + [chainId: number]: { + [filterKey: string]: { + listeners: number + fetchingBlockNumber?: number + results?: + | { + blockNumber: number + logs: Log[] + error?: undefined + } + | { + blockNumber: number + logs?: undefined + error: true + } + } + } +} + +assert>() diff --git a/src/state/transactions/reducer.test.ts b/src/state/transactions/reducer.test.ts index d4b178d43a..4a472d2411 100644 --- a/src/state/transactions/reducer.test.ts +++ b/src/state/transactions/reducer.test.ts @@ -1,7 +1,6 @@ import { ChainId } from '@uniswap/sdk-core' import { createStore, Store } from 'redux' -import { updateVersion } from '../global/actions' import reducer, { addTransaction, cancelTransaction, @@ -20,32 +19,6 @@ describe('transaction reducer', () => { store = createStore(reducer, initialState) }) - describe('updateVersion', () => { - it('clears old format transactions that do not have info', () => { - store = createStore(reducer, { - 1: { - abc: { - hash: 'abc', - } as any, - }, - }) - store.dispatch(updateVersion()) - expect(store.getState()[ChainId.MAINNET]['abc']).toBeUndefined() - }) - it('keeps old format transactions that do have info', () => { - store = createStore(reducer, { - 1: { - abc: { - hash: 'abc', - info: {}, - } as any, - }, - }) - store.dispatch(updateVersion()) - expect(store.getState()[ChainId.MAINNET]['abc']).toBeTruthy() - }) - }) - describe('addTransaction', () => { it('adds the transaction', () => { const beforeTime = new Date().getTime() diff --git a/src/state/transactions/reducer.ts b/src/state/transactions/reducer.ts index 9848a5b3d2..1a1a1da4ae 100644 --- a/src/state/transactions/reducer.ts +++ b/src/state/transactions/reducer.ts @@ -1,7 +1,6 @@ import { createSlice } from '@reduxjs/toolkit' import { ChainId } from '@uniswap/sdk-core' -import { updateVersion } from '../global/actions' import { SerializableTransactionReceipt, TransactionDetails, TransactionInfo } from './types' // TODO(WEB-2053): update this to be a map of account -> chainId -> txHash -> TransactionDetails @@ -80,20 +79,6 @@ const transactionSlice = createSlice({ } }, }, - extraReducers: (builder) => { - builder.addCase(updateVersion, (transactions) => { - // in case there are any transactions in the store with the old format, remove them - Object.keys(transactions).forEach((chainId) => { - const chainTransactions = transactions[chainId as unknown as number] - Object.keys(chainTransactions).forEach((hash) => { - if (!('info' in chainTransactions[hash])) { - // clear old transactions that don't have the right format - delete chainTransactions[hash] - } - }) - }) - }) - }, }) export const { diff --git a/src/state/types.ts b/src/state/types.ts deleted file mode 100644 index fd59f95141..0000000000 --- a/src/state/types.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Reducer } from '@reduxjs/toolkit' - -import reducer from './reducer' - -/* Utility type to extract state type out of a @reduxjs/toolkit Reducer type */ -type GetState = T extends Reducer ? State : never - -export type AppState = { - [K in keyof typeof reducer]: GetState<(typeof reducer)[K]> -} diff --git a/src/state/user/hooks.tsx b/src/state/user/hooks.tsx index 0836e2db18..ef5e36b1fc 100644 --- a/src/state/user/hooks.tsx +++ b/src/state/user/hooks.tsx @@ -12,7 +12,7 @@ import { UserAddedToken } from 'types/tokens' import { BASES_TO_TRACK_LIQUIDITY_FOR, PINNED_PAIRS } from '../../constants/routing' import { useDefaultActiveTokens } from '../../hooks/Tokens' -import { AppState } from '../types' +import { AppState } from '../reducer' import { addSerializedPair, addSerializedToken, diff --git a/src/state/user/reducer.test.ts b/src/state/user/reducer.test.ts index 03091ef915..893dfa719d 100644 --- a/src/state/user/reducer.test.ts +++ b/src/state/user/reducer.test.ts @@ -1,8 +1,6 @@ import { createStore, Store } from 'redux' import { RouterPreference } from 'state/routing/types' -import { DEFAULT_DEADLINE_FROM_NOW } from '../../constants/misc' -import { updateVersion } from '../global/actions' import reducer, { addSerializedPair, addSerializedToken, @@ -16,7 +14,6 @@ import reducer, { updateUserSlippageTolerance, UserState, } from './reducer' -import { SlippageTolerance } from './types' function buildSerializedPair(token0Address: string, token1Address: string, chainId: number) { return { @@ -38,36 +35,6 @@ describe('swap reducer', () => { store = createStore(reducer, initialState) }) - describe('updateVersion', () => { - it('has no timestamp originally', () => { - expect(store.getState().lastUpdateVersionTimestamp).toBeUndefined() - }) - it('sets the lastUpdateVersionTimestamp', () => { - const time = new Date().getTime() - store.dispatch(updateVersion()) - expect(store.getState().lastUpdateVersionTimestamp).toBeGreaterThanOrEqual(time) - }) - it('sets allowed slippage and deadline', () => { - store = createStore(reducer, { - ...initialState, - userDeadline: undefined, - userSlippageTolerance: undefined, - } as any) - store.dispatch(updateVersion()) - expect(store.getState().userDeadline).toEqual(DEFAULT_DEADLINE_FROM_NOW) - expect(store.getState().userSlippageTolerance).toEqual(SlippageTolerance.Auto) - }) - it('sets allowed slippage and deadline to auto', () => { - store = createStore(reducer, { - ...initialState, - userSlippageTolerance: 10, - userSlippageToleranceHasBeenMigratedToAuto: undefined, - } as any) - store.dispatch(updateVersion()) - expect(store.getState().userSlippageToleranceHasBeenMigratedToAuto).toEqual(true) - }) - }) - describe('updateSelectedWallet', () => { it('updates the selected wallet', () => { store.dispatch(updateSelectedWallet({ wallet: 'metamask' })) diff --git a/src/state/user/reducer.ts b/src/state/user/reducer.ts index c1fcea853e..d717e7f413 100644 --- a/src/state/user/reducer.ts +++ b/src/state/user/reducer.ts @@ -4,7 +4,6 @@ import { ConnectionType } from '../../connection/types' import { SupportedLocale } from '../../constants/locales' import { DEFAULT_DEADLINE_FROM_NOW } from '../../constants/misc' import { RouterPreference } from '../../state/routing/types' -import { updateVersion } from '../global/actions' import { SerializedPair, SerializedToken, SlippageTolerance } from './types' const currentTimestamp = () => new Date().getTime() @@ -124,85 +123,6 @@ const userSlice = createSlice({ state.timestamp = currentTimestamp() }, }, - extraReducers: (builder) => { - // After adding a new property to the state, its value will be `undefined` (instead of the default) - // for all existing users with a previous version of the state in their localStorage. - // In order to avoid this, we need to set a default value for each new property manually during hydration. - builder.addCase(updateVersion, (state) => { - // If `selectedWallet` is a WalletConnect v1 wallet, reset to default. - if (state.selectedWallet) { - const selectedWallet = state.selectedWallet as string - if ( - selectedWallet === 'UNIWALLET' || - selectedWallet === 'UNISWAP_WALLET' || - selectedWallet === 'WALLET_CONNECT' - ) { - delete state.selectedWallet - } - } - - // If `userSlippageTolerance` is not present or its value is invalid, reset to default - if ( - typeof state.userSlippageTolerance !== 'number' || - !Number.isInteger(state.userSlippageTolerance) || - state.userSlippageTolerance < 0 || - state.userSlippageTolerance > 5000 - ) { - state.userSlippageTolerance = SlippageTolerance.Auto - } else { - if ( - !state.userSlippageToleranceHasBeenMigratedToAuto && - [10, 50, 100].indexOf(state.userSlippageTolerance) !== -1 - ) { - state.userSlippageTolerance = SlippageTolerance.Auto - state.userSlippageToleranceHasBeenMigratedToAuto = true - } - } - - // If `userDeadline` is not present or its value is invalid, reset to default - if ( - typeof state.userDeadline !== 'number' || - !Number.isInteger(state.userDeadline) || - state.userDeadline < 60 || - state.userDeadline > 180 * 60 - ) { - state.userDeadline = DEFAULT_DEADLINE_FROM_NOW - } - - // If `userRouterPreference` is not present, reset to default - if (typeof state.userRouterPreference !== 'string') { - state.userRouterPreference = RouterPreference.API - } - - // If `userRouterPreference` is `AUTO`, migrate to `API` - if ((state.userRouterPreference as string) === 'auto') { - state.userRouterPreference = RouterPreference.API - } - - //If `buyFiatFlowCompleted` is present, delete it using filtering - if ('buyFiatFlowCompleted' in state) { - //ignoring due to type errors occuring since we now remove this state - //@ts-ignore - delete state.buyFiatFlowCompleted - } - - // If `buyFiatFlowCompleted` is present, delete it using filtering - if ('buyFiatFlowCompleted' in state) { - //ignoring due to type errors occuring since we now remove this state - //@ts-ignore - delete state.buyFiatFlowCompleted - } - - //If `buyFiatFlowCompleted` is present, delete it using filtering - if ('buyFiatFlowCompleted' in state) { - //ignoring due to type errors occuring since we now remove this state - //@ts-ignore - delete state.buyFiatFlowCompleted - } - - state.lastUpdateVersionTimestamp = currentTimestamp() - }) - }, }) export const { diff --git a/src/state/wallets/reducer.ts b/src/state/wallets/reducer.ts index e63972f13a..87404d1cf3 100644 --- a/src/state/wallets/reducer.ts +++ b/src/state/wallets/reducer.ts @@ -4,7 +4,7 @@ import { shallowEqual } from 'react-redux' import { Wallet } from './types' -interface WalletState { +export interface WalletState { // Used to track wallets that have been connected by the user in current session, and remove them when deliberately disconnected. // Used to compute is_reconnect event property for analytics connectedWallets: Wallet[] diff --git a/yarn.lock b/yarn.lock index 481344cda7..a03a208e6f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12952,6 +12952,11 @@ image-q@^1.1.1: resolved "https://registry.npmjs.org/image-q/-/image-q-1.1.1.tgz" integrity sha1-/IQJlmRGC5DKhi2TALa/u7+/gFY= +immediate@~3.0.5: + version "3.0.6" + resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" + integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ== + immer@^9.0.21, immer@^9.0.6, immer@^9.0.7: version "9.0.21" resolved "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz#1e025ea31a40f24fb064f1fef23e931496330176" @@ -14896,6 +14901,13 @@ levn@~0.3.0: prelude-ls "~1.1.2" type-check "~0.3.2" +lie@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/lie/-/lie-3.1.1.tgz#9a436b2cc7746ca59de7a41fa469b3efb76bd87e" + integrity sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw== + dependencies: + immediate "~3.0.5" + lilconfig@^2.0.3, lilconfig@^2.0.5, lilconfig@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.1.0.tgz#78e23ac89ebb7e1bfbf25b18043de756548e7f52" @@ -14999,6 +15011,13 @@ loader-utils@^3.2.0: resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-3.2.1.tgz#4fb104b599daafd82ef3e1a41fb9265f87e1f576" integrity sha512-ZvFw1KWS3GVyYBYb7qkmRM/WwL2TQQBxgCK62rlvm4WpVQ23Nb4tYjApUlfjrEGvOs7KHEsmyUn75OHZrJMWPw== +localforage@^1.10.0: + version "1.10.0" + resolved "https://registry.yarnpkg.com/localforage/-/localforage-1.10.0.tgz#5c465dc5f62b2807c3a84c0c6a1b1b3212781dd4" + integrity sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg== + dependencies: + lie "3.1.1" + locate-path@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e" @@ -15298,11 +15317,6 @@ merge2@^1.3.0, merge2@^1.4.1: resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== -merge@2.1.0: - version "2.1.0" - resolved "https://registry.npmjs.org/merge/-/merge-2.1.0.tgz" - integrity sha512-TcuhVDV+e6X457MQAm7xIb19rWhZuEDEho7RrwxMpQ/3GhD5sDlnP188gjQQuweXHy9igdke5oUtVOXX1X8Sxg== - merkletreejs@^0.3.9: version "0.3.9" resolved "https://registry.yarnpkg.com/merkletreejs/-/merkletreejs-0.3.9.tgz#cdb364a3b974a44f4eff3446522d7066e0cf95de" @@ -17861,12 +17875,10 @@ reduce-function-call@^1.0.1: dependencies: balanced-match "^1.0.0" -redux-localstorage-simple@^2.3.1: - version "2.4.0" - resolved "https://registry.npmjs.org/redux-localstorage-simple/-/redux-localstorage-simple-2.4.0.tgz" - integrity sha512-Zj28elJtO4fqXXC+gikonbKhFUkiwlalScYRn3EGUU44Pika1995AqUgzjIcsSPlBhIDV2WudFqa/YI9+3aE9Q== - dependencies: - merge "2.1.0" +redux-persist@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/redux-persist/-/redux-persist-6.0.0.tgz#b4d2972f9859597c130d40d4b146fecdab51b3a8" + integrity sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ== redux-thunk@^2.4.2: version "2.4.2" @@ -19846,6 +19858,11 @@ ts-transform-graphql-tag@^0.2.1: resolved "https://registry.yarnpkg.com/ts-transform-graphql-tag/-/ts-transform-graphql-tag-0.2.1.tgz#f596c491196b6a6a65b65a8b99bf6e2314c78017" integrity sha512-gciNzCpVafccayI/VQKU2ROaol4gMpz0t5sAW/jzG/J/wnjPYCn06yKzlM4mkQ5tjCvPmFuZnLYF4i0tUiIiMQ== +tsafe@^1.6.4: + version "1.6.4" + resolved "https://registry.yarnpkg.com/tsafe/-/tsafe-1.6.4.tgz#048a114761714538c72f16abd25bb247d4e3780e" + integrity sha512-l4Z54QFGHO8GF0gBpb3yPGHjkIkIirl8rwW+lMBmtEMzOJeRs8BdjkDEx6nU8Ak9PQVp/KNDtECxTja8MMIDoA== + tsconfig-paths@^3.14.1: version "3.14.1" resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz#ba0734599e8ea36c862798e920bcf163277b137a"