From 0e06735731243f9453c5b288167046b68e547074 Mon Sep 17 00:00:00 2001 From: Myroslav Sviderok Date: Sun, 6 Oct 2024 22:34:19 +0300 Subject: [PATCH 01/16] add RTK-query, add api slice for Transaction Feed V2 --- src/redux/api.ts | 20 ++ src/redux/migrations.test.ts | 25 +++ src/redux/migrations.ts | 21 ++ src/redux/reducers.ts | 3 + src/redux/store.test.ts | 21 +- src/redux/store.ts | 9 +- src/transactions/api.ts | 34 ++++ test/RootStateSchema.json | 385 +++++++++++++++++++++++++++++++++++ test/schemas.ts | 28 ++- 9 files changed, 541 insertions(+), 5 deletions(-) create mode 100644 src/redux/api.ts create mode 100644 src/transactions/api.ts diff --git a/src/redux/api.ts b/src/redux/api.ts new file mode 100644 index 00000000000..fc95b6b0f8c --- /dev/null +++ b/src/redux/api.ts @@ -0,0 +1,20 @@ +import { fetchBaseQuery } from '@reduxjs/toolkit/query/react' +import { REHYDRATE } from 'redux-persist' +import type { Action } from 'redux-saga' +import type { RootState } from 'src/redux/reducers' +import networkConfig from 'src/web3/networkConfig' + +export const baseQuery = fetchBaseQuery({ + baseUrl: networkConfig.blockchainApiUrl, + headers: { + Accept: 'application/json', + }, +}) + +export function isRehydrateAction(action: Action): action is Action & { + key: string + payload: RootState + err: unknown +} { + return action.type === REHYDRATE +} diff --git a/src/redux/migrations.test.ts b/src/redux/migrations.test.ts index 7dbe37a23a5..db390513a33 100644 --- a/src/redux/migrations.test.ts +++ b/src/redux/migrations.test.ts @@ -3,6 +3,7 @@ import _ from 'lodash' import { FinclusiveKycStatus } from 'src/account/reducer' import { DEEP_LINK_URL_SCHEME } from 'src/config' import { exchangeInitialState, migrations } from 'src/redux/migrations' +import { transactionFeedV2Api } from 'src/transactions/api' import { Network, NetworkId, @@ -57,6 +58,7 @@ import { v227Schema, v228Schema, v230Schema, + v232Schema, v28Schema, v2Schema, v35Schema, @@ -1703,4 +1705,27 @@ describe('Redux persist migrations', () => { expectedSchema.jumpstart.introHasBeenSeen = false expect(migratedSchema).toStrictEqual(expectedSchema) }) + it('works from 232 to 233', () => { + const oldSchema = v232Schema + const migratedSchema = migrations[233](oldSchema) + const expectedSchema: any = _.cloneDeep(oldSchema) + expectedSchema[transactionFeedV2Api.reducerPath] = { + config: { + focused: true, + invalidationBehavior: 'delayed', + keepUnusedDataFor: 60, + middlewareRegistered: true, + online: true, + reducerPath: transactionFeedV2Api.reducerPath, + refetchOnFocus: false, + refetchOnMountOrArgChange: false, + refetchOnReconnect: false, + }, + mutations: {}, + provided: {}, + queries: {}, + subscriptions: {}, + } + expect(migratedSchema).toStrictEqual(expectedSchema) + }) }) diff --git a/src/redux/migrations.ts b/src/redux/migrations.ts index 6b910d8f7eb..2840b006623 100644 --- a/src/redux/migrations.ts +++ b/src/redux/migrations.ts @@ -10,6 +10,7 @@ import { LocalCurrencyCode } from 'src/localCurrency/consts' import { Screens } from 'src/navigator/Screens' import { Position } from 'src/positions/types' import { Recipient } from 'src/recipients/recipient' +import { transactionFeedV2Api } from 'src/transactions/api' import { Network, NetworkId, StandbyTransaction, TokenTransaction } from 'src/transactions/types' import { CiCoCurrency, Currency } from 'src/utils/currencies' import networkConfig from 'src/web3/networkConfig' @@ -1916,4 +1917,24 @@ export const migrations = { ...state, app: _.omit(state.app, 'numberVerified'), }), + 233: (state: any) => ({ + ...state, + [transactionFeedV2Api.reducerPath]: { + config: { + focused: true, + invalidationBehavior: 'delayed', + keepUnusedDataFor: 60, + middlewareRegistered: true, + online: true, + reducerPath: transactionFeedV2Api.reducerPath, + refetchOnFocus: false, + refetchOnMountOrArgChange: false, + refetchOnReconnect: false, + }, + mutations: {}, + provided: {}, + queries: {}, + subscriptions: {}, + }, + }), } diff --git a/src/redux/reducers.ts b/src/redux/reducers.ts index f9520956ed8..d882fa2cea9 100644 --- a/src/redux/reducers.ts +++ b/src/redux/reducers.ts @@ -24,6 +24,7 @@ import { recipientsReducer as recipients } from 'src/recipients/reducer' import { sendReducer as send } from 'src/send/reducers' import swapReducer from 'src/swap/slice' import tokenReducer from 'src/tokens/slice' +import { transactionFeedV2Api } from 'src/transactions/api' import { reducer as transactions } from 'src/transactions/reducer' import { reducer as walletConnect } from 'src/walletConnect/reducer' import { reducer as web3 } from 'src/web3/reducer' @@ -55,6 +56,8 @@ const appReducer = combineReducers({ jumpstart: jumpstartReducer, points: pointsReducer, earn: earnReducer, + + [transactionFeedV2Api.reducerPath]: transactionFeedV2Api.reducer, }) const rootReducer = (state: RootState | undefined, action: Action): RootState => { diff --git a/src/redux/store.test.ts b/src/redux/store.test.ts index 04e47fc4450..a79ce5888da 100644 --- a/src/redux/store.test.ts +++ b/src/redux/store.test.ts @@ -83,7 +83,7 @@ describe('store state', () => { const data = store.getState() - const ajv = new Ajv({ allErrors: true }) + const ajv = new Ajv({ allErrors: true, allowUnionTypes: true }) const schema = require('test/RootStateSchema.json') const validate = ajv.compile(schema) const isValid = validate(data) @@ -98,7 +98,7 @@ describe('store state', () => { { "_persist": { "rehydrated": true, - "version": 232, + "version": 233, }, "account": { "acceptedTerms": false, @@ -326,6 +326,23 @@ describe('store state', () => { "loading": false, "tokenBalances": {}, }, + "transactionFeedV2Api": { + "config": { + "focused": true, + "invalidationBehavior": "delayed", + "keepUnusedDataFor": 60, + "middlewareRegistered": true, + "online": true, + "reducerPath": "transactionFeedV2Api", + "refetchOnFocus": false, + "refetchOnMountOrArgChange": false, + "refetchOnReconnect": false, + }, + "mutations": {}, + "provided": {}, + "queries": {}, + "subscriptions": {}, + }, "transactions": { "standbyTransactions": [], "transactionsByNetworkId": {}, diff --git a/src/redux/store.ts b/src/redux/store.ts index 84d37a8c124..1b67c158d1b 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -1,5 +1,6 @@ import AsyncStorage from '@react-native-async-storage/async-storage' import { configureStore, Middleware } from '@reduxjs/toolkit' +import { setupListeners } from '@reduxjs/toolkit/query' import { getStoredState, PersistConfig, persistReducer, persistStore } from 'redux-persist' import FSStorage from 'redux-persist-fs-storage' import autoMergeLevel2 from 'redux-persist/lib/stateReconciler/autoMergeLevel2' @@ -10,6 +11,7 @@ import { createMigrate } from 'src/redux/createMigrate' import { migrations } from 'src/redux/migrations' import rootReducer, { RootState as ReducersRootState } from 'src/redux/reducers' import { rootSaga } from 'src/redux/sagas' +import { transactionFeedV2Api } from 'src/transactions/api' import { resetStateOnInvalidStoredAccount } from 'src/utils/accountChecker' import Logger from 'src/utils/Logger' import { ONE_DAY_IN_MILLIS } from 'src/utils/time' @@ -21,7 +23,7 @@ const persistConfig: PersistConfig = { key: 'root', // default is -1, increment as we make migrations // See https://github.com/valora-inc/wallet/tree/main/WALLET.md#redux-state-migration - version: 232, + version: 233, keyPrefix: `reduxStore-`, // the redux-persist default is `persist:` which doesn't work with some file systems. storage: FSStorage(), blacklist: ['networkInfo', 'alert', 'imports', 'keylessBackup', 'jumpstart'], @@ -103,7 +105,8 @@ export const setupStore = (initialState?: ReducersRootState, config = persistCon ) }, }) - const middlewares: Middleware[] = [sagaMiddleware] + + const middlewares: Middleware[] = [sagaMiddleware, transactionFeedV2Api.middleware] if (__DEV__ && !process.env.JEST_WORKER_ID) { const createDebugger = require('redux-flipper').default @@ -172,3 +175,5 @@ export { persistor, store } export type RootState = ReturnType export type AppDispatch = typeof store.dispatch + +setupListeners(store.dispatch) diff --git a/src/transactions/api.ts b/src/transactions/api.ts new file mode 100644 index 00000000000..2d147abb423 --- /dev/null +++ b/src/transactions/api.ts @@ -0,0 +1,34 @@ +import { createApi } from '@reduxjs/toolkit/query/react' +import { baseQuery, isRehydrateAction } from 'src/redux/api' +import type { TokenTransaction } from 'src/transactions/types' + +type TransactionFeedV2Response = { + transactions: TokenTransaction[] + pageInfo: { + hasNextPage: boolean + } +} + +export const transactionFeedV2Api = createApi({ + reducerPath: 'transactionFeedV2Api', + baseQuery, + endpoints: (builder) => ({ + transactionFeedV2: builder.query< + TransactionFeedV2Response, + { address: string; endCursor: number } + >({ + query: ({ address, endCursor }) => `/wallet/${address}/transactions?endCursor=${endCursor}`, + }), + }), + extractRehydrationInfo: (action, { reducerPath }): any => { + if (isRehydrateAction(action)) { + /** + * Even though payload is types as RootState - redux-persist can evaluate it as undefined. + */ + return action.payload?.[reducerPath] + } + }, +}) + +const { useTransactionFeedV2Query } = transactionFeedV2Api +export { useTransactionFeedV2Query } diff --git a/test/RootStateSchema.json b/test/RootStateSchema.json index 550615e97d8..db51d75af91 100644 --- a/test/RootStateSchema.json +++ b/test/RootStateSchema.json @@ -859,6 +859,62 @@ ], "type": "object" }, + "ConfigState<\"transactionFeedV2Api\">": { + "additionalProperties": false, + "properties": { + "focused": { + "type": "boolean" + }, + "invalidationBehavior": { + "enum": [ + "delayed", + "immediately" + ], + "type": "string" + }, + "keepUnusedDataFor": { + "type": "number" + }, + "middlewareRegistered": { + "enum": [ + "conflict", + false, + true + ] + }, + "online": { + "type": "boolean" + }, + "reducerPath": { + "const": "transactionFeedV2Api", + "type": "string" + }, + "refetchOnFocus": { + "type": "boolean" + }, + "refetchOnMountOrArgChange": { + "type": [ + "number", + "boolean" + ] + }, + "refetchOnReconnect": { + "type": "boolean" + } + }, + "required": [ + "focused", + "invalidationBehavior", + "keepUnusedDataFor", + "middlewareRegistered", + "online", + "reducerPath", + "refetchOnFocus", + "refetchOnMountOrArgChange", + "refetchOnReconnect" + ], + "type": "object" + }, "ContactRecipient": { "anyOf": [ { @@ -1944,6 +2000,10 @@ ], "type": "number" }, + "InvalidationState": { + "additionalProperties": false, + "type": "object" + }, "KeylessBackupDeleteStatus": { "enum": [ "Completed", @@ -7035,6 +7095,148 @@ }, "type": "object" }, + "{status:QueryStatus.fulfilled;error:undefined;originalArgs:unknown;requestId:string;endpointName:string;startedTimeStamp:number;data:unknown;fulfilledTimeStamp:number;}": { + "additionalProperties": false, + "properties": { + "data": { + "description": "The received data from the query" + }, + "endpointName": { + "description": "The name of the endpoint associated with the query", + "type": "string" + }, + "fulfilledTimeStamp": { + "description": "Time that the latest query was fulfilled", + "type": "number" + }, + "originalArgs": { + "description": "The argument originally passed into the hook or `initiate` action call" + }, + "requestId": { + "description": "A unique ID associated with the request", + "type": "string" + }, + "startedTimeStamp": { + "description": "Time that the latest query started", + "type": "number" + }, + "status": { + "const": "fulfilled", + "type": "string" + } + }, + "required": [ + "data", + "endpointName", + "fulfilledTimeStamp", + "originalArgs", + "requestId", + "startedTimeStamp", + "status" + ], + "type": "object" + }, + "{status:QueryStatus.pending;originalArgs:unknown;requestId:string;data?:unknown;error?:unknown;endpointName:string;startedTimeStamp:number;fulfilledTimeStamp?:number|undefined;}": { + "additionalProperties": false, + "properties": { + "data": { + "description": "The received data from the query" + }, + "endpointName": { + "description": "The name of the endpoint associated with the query", + "type": "string" + }, + "error": { + "description": "The received error if applicable" + }, + "fulfilledTimeStamp": { + "description": "Time that the latest query was fulfilled", + "type": "number" + }, + "originalArgs": { + "description": "The argument originally passed into the hook or `initiate` action call" + }, + "requestId": { + "description": "A unique ID associated with the request", + "type": "string" + }, + "startedTimeStamp": { + "description": "Time that the latest query started", + "type": "number" + }, + "status": { + "const": "pending", + "type": "string" + } + }, + "required": [ + "endpointName", + "originalArgs", + "requestId", + "startedTimeStamp", + "status" + ], + "type": "object" + }, + "{status:QueryStatus.rejected;data?:unknown;fulfilledTimeStamp?:number|undefined;originalArgs:unknown;requestId:string;endpointName:string;startedTimeStamp:number;error:unknown;}": { + "additionalProperties": false, + "properties": { + "data": { + "description": "The received data from the query" + }, + "endpointName": { + "description": "The name of the endpoint associated with the query", + "type": "string" + }, + "error": { + "description": "The received error if applicable" + }, + "fulfilledTimeStamp": { + "description": "Time that the latest query was fulfilled", + "type": "number" + }, + "originalArgs": { + "description": "The argument originally passed into the hook or `initiate` action call" + }, + "requestId": { + "description": "A unique ID associated with the request", + "type": "string" + }, + "startedTimeStamp": { + "description": "Time that the latest query started", + "type": "number" + }, + "status": { + "const": "rejected", + "type": "string" + } + }, + "required": [ + "endpointName", + "error", + "originalArgs", + "requestId", + "startedTimeStamp", + "status" + ], + "type": "object" + }, + "{status:QueryStatus.uninitialized;originalArgs?:undefined;data?:undefined;error?:undefined;requestId?:undefined;endpointName?:string|undefined;startedTimeStamp?:undefined;fulfilledTimeStamp?:undefined;}": { + "additionalProperties": false, + "properties": { + "endpointName": { + "type": "string" + }, + "status": { + "const": "uninitialized", + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + }, "{swap?:boolean|undefined;\"create-wallet\"?:boolean|undefined;\"create-live-link\"?:boolean|undefined;\"deposit-earn\"?:boolean|undefined;}": { "additionalProperties": false, "properties": { @@ -7216,6 +7418,188 @@ "tokens": { "$ref": "#/definitions/State_15" }, + "transactionFeedV2Api": { + "additionalProperties": false, + "properties": { + "config": { + "$ref": "#/definitions/ConfigState<\"transactionFeedV2Api\">" + }, + "mutations": { + "additionalProperties": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "endpointName": { + "type": "string" + }, + "status": { + "const": "uninitialized", + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "data": {}, + "endpointName": { + "type": "string" + }, + "error": {}, + "fulfilledTimeStamp": { + "type": "number" + }, + "requestId": { + "type": "string" + }, + "startedTimeStamp": { + "type": "number" + }, + "status": { + "const": "fulfilled", + "type": "string" + } + }, + "required": [ + "data", + "endpointName", + "fulfilledTimeStamp", + "requestId", + "startedTimeStamp", + "status" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "data": {}, + "endpointName": { + "type": "string" + }, + "error": {}, + "fulfilledTimeStamp": { + "type": "number" + }, + "requestId": { + "type": "string" + }, + "startedTimeStamp": { + "type": "number" + }, + "status": { + "const": "pending", + "type": "string" + } + }, + "required": [ + "endpointName", + "requestId", + "startedTimeStamp", + "status" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "data": {}, + "endpointName": { + "type": "string" + }, + "error": {}, + "fulfilledTimeStamp": { + "type": "number" + }, + "requestId": { + "type": "string" + }, + "startedTimeStamp": { + "type": "number" + }, + "status": { + "const": "rejected", + "type": "string" + } + }, + "required": [ + "endpointName", + "error", + "requestId", + "startedTimeStamp", + "status" + ], + "type": "object" + } + ] + }, + "type": "object" + }, + "provided": { + "$ref": "#/definitions/InvalidationState" + }, + "queries": { + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/definitions/{status:QueryStatus.uninitialized;originalArgs?:undefined;data?:undefined;error?:undefined;requestId?:undefined;endpointName?:string|undefined;startedTimeStamp?:undefined;fulfilledTimeStamp?:undefined;}" + }, + { + "$ref": "#/definitions/{status:QueryStatus.fulfilled;error:undefined;originalArgs:unknown;requestId:string;endpointName:string;startedTimeStamp:number;data:unknown;fulfilledTimeStamp:number;}" + }, + { + "$ref": "#/definitions/{status:QueryStatus.pending;originalArgs:unknown;requestId:string;data?:unknown;error?:unknown;endpointName:string;startedTimeStamp:number;fulfilledTimeStamp?:number|undefined;}" + }, + { + "$ref": "#/definitions/{status:QueryStatus.rejected;data?:unknown;fulfilledTimeStamp?:number|undefined;originalArgs:unknown;requestId:string;endpointName:string;startedTimeStamp:number;error:unknown;}" + } + ] + }, + "type": "object" + }, + "subscriptions": { + "additionalProperties": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "pollingInterval": { + "description": "How frequently to automatically re-fetch data (in milliseconds). Defaults to `0` (off).", + "type": "number" + }, + "refetchOnFocus": { + "description": "Defaults to `false`. This setting allows you to control whether RTK Query will try to refetch all subscribed queries after the application window regains focus.\n\nIf you specify this option alongside `skip: true`, this **will not be evaluated** until `skip` is false.\n\nNote: requires [`setupListeners`](./setupListeners) to have been called.", + "type": "boolean" + }, + "refetchOnReconnect": { + "description": "Defaults to `false`. This setting allows you to control whether RTK Query will try to refetch all subscribed queries after regaining a network connection.\n\nIf you specify this option alongside `skip: true`, this **will not be evaluated** until `skip` is false.\n\nNote: requires [`setupListeners`](./setupListeners) to have been called.", + "type": "boolean" + }, + "skipPollingIfUnfocused": { + "description": "Defaults to 'false'. This setting allows you to control whether RTK Query will continue polling if the window is not focused.\n\nIf pollingInterval is not set or set to 0, this **will not be evaluated** until pollingInterval is greater than 0.\n\nNote: requires [`setupListeners`](./setupListeners) to have been called.", + "type": "boolean" + } + }, + "type": "object" + }, + "type": "object" + }, + "type": "object" + } + }, + "required": [ + "config", + "mutations", + "provided", + "queries", + "subscriptions" + ], + "type": "object" + }, "transactions": { "$ref": "#/definitions/State_6" }, @@ -7251,6 +7635,7 @@ "send", "swap", "tokens", + "transactionFeedV2Api", "transactions", "walletConnect", "web3" diff --git a/test/schemas.ts b/test/schemas.ts index 6a8c65581e6..b634c0096bc 100644 --- a/test/schemas.ts +++ b/test/schemas.ts @@ -11,6 +11,7 @@ import { KeylessBackupDeleteStatus, KeylessBackupStatus } from 'src/keylessBacku import { LocalCurrencyCode } from 'src/localCurrency/consts' import { updateCachedQuoteParams } from 'src/redux/migrations' import { RootState } from 'src/redux/store' +import { transactionFeedV2Api } from 'src/transactions/api' import { Network, NetworkId, StandbyTransaction, TokenTransaction } from 'src/transactions/types' import { CiCoCurrency, Currency } from 'src/utils/currencies' import networkConfig from 'src/web3/networkConfig' @@ -3533,6 +3534,31 @@ export const v232Schema = { app: _.omit(v231Schema.app, 'numberVerified'), } +export const v233Schema = { + ...v232Schema, + _persist: { + ...v232Schema._persist, + version: 233, + }, + [transactionFeedV2Api.reducerPath]: { + config: { + focused: true, + invalidationBehavior: 'delayed', + keepUnusedDataFor: 60, + middlewareRegistered: true, + online: true, + reducerPath: transactionFeedV2Api.reducerPath, + refetchOnFocus: false, + refetchOnMountOrArgChange: false, + refetchOnReconnect: false, + }, + mutations: {}, + provided: {}, + queries: {}, + subscriptions: {}, + }, +} + export function getLatestSchema(): Partial { - return v232Schema as Partial + return v233Schema as Partial } From fff449a712c89d778e72608c53ec3d50087372de Mon Sep 17 00:00:00 2001 From: Myroslav Sviderok Date: Sun, 6 Oct 2024 22:52:04 +0300 Subject: [PATCH 02/16] remove unused export --- src/transactions/api.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/transactions/api.ts b/src/transactions/api.ts index 2d147abb423..7bd0dca99c8 100644 --- a/src/transactions/api.ts +++ b/src/transactions/api.ts @@ -29,6 +29,3 @@ export const transactionFeedV2Api = createApi({ } }, }) - -const { useTransactionFeedV2Query } = transactionFeedV2Api -export { useTransactionFeedV2Query } From 0168d7fee262c704efcf4f05ac347f5925193b8e Mon Sep 17 00:00:00 2001 From: Myroslav Sviderok Date: Mon, 7 Oct 2024 11:39:21 +0300 Subject: [PATCH 03/16] Remove api reducers from schema-checking --- scripts/update_root_state_schema.sh | 2 +- src/redux/apiReducersList.ts | 7 + src/redux/migrations.test.ts | 25 -- src/redux/migrations.ts | 22 +- src/redux/reducers.ts | 65 +--- src/redux/reducersForSchemaGeneration.ts | 8 + src/redux/reducersList.ts | 55 ++++ src/redux/store.test.ts | 31 +- src/redux/store.ts | 9 +- test/RootStateSchema.json | 385 ----------------------- test/schema.test.ts | 2 +- test/schemas.ts | 18 -- 12 files changed, 100 insertions(+), 529 deletions(-) create mode 100644 src/redux/apiReducersList.ts create mode 100644 src/redux/reducersForSchemaGeneration.ts create mode 100644 src/redux/reducersList.ts diff --git a/scripts/update_root_state_schema.sh b/scripts/update_root_state_schema.sh index 0a229ec6a44..b72a686d313 100755 --- a/scripts/update_root_state_schema.sh +++ b/scripts/update_root_state_schema.sh @@ -7,7 +7,7 @@ set -euo pipefail root_state_schema="test/RootStateSchema.json" -typescript-json-schema ./tsconfig.json RootState --include src/redux/reducers.ts --ignoreErrors --required --noExtraProps > "$root_state_schema" +typescript-json-schema ./tsconfig.json RootStateForSchemaGeneration --include src/redux/reducersForSchemaGeneration.ts --ignoreErrors --required --noExtraProps > "$root_state_schema" if git diff --exit-code "$root_state_schema"; then echo "$root_state_schema is up to date" diff --git a/src/redux/apiReducersList.ts b/src/redux/apiReducersList.ts new file mode 100644 index 00000000000..b1a03d374de --- /dev/null +++ b/src/redux/apiReducersList.ts @@ -0,0 +1,7 @@ +import { transactionFeedV2Api } from 'src/transactions/api' + +export const apiReducersList = { + [transactionFeedV2Api.reducerPath]: transactionFeedV2Api.reducer, +} as const + +export type ApiReducersKeys = keyof typeof apiReducersList diff --git a/src/redux/migrations.test.ts b/src/redux/migrations.test.ts index db390513a33..7dbe37a23a5 100644 --- a/src/redux/migrations.test.ts +++ b/src/redux/migrations.test.ts @@ -3,7 +3,6 @@ import _ from 'lodash' import { FinclusiveKycStatus } from 'src/account/reducer' import { DEEP_LINK_URL_SCHEME } from 'src/config' import { exchangeInitialState, migrations } from 'src/redux/migrations' -import { transactionFeedV2Api } from 'src/transactions/api' import { Network, NetworkId, @@ -58,7 +57,6 @@ import { v227Schema, v228Schema, v230Schema, - v232Schema, v28Schema, v2Schema, v35Schema, @@ -1705,27 +1703,4 @@ describe('Redux persist migrations', () => { expectedSchema.jumpstart.introHasBeenSeen = false expect(migratedSchema).toStrictEqual(expectedSchema) }) - it('works from 232 to 233', () => { - const oldSchema = v232Schema - const migratedSchema = migrations[233](oldSchema) - const expectedSchema: any = _.cloneDeep(oldSchema) - expectedSchema[transactionFeedV2Api.reducerPath] = { - config: { - focused: true, - invalidationBehavior: 'delayed', - keepUnusedDataFor: 60, - middlewareRegistered: true, - online: true, - reducerPath: transactionFeedV2Api.reducerPath, - refetchOnFocus: false, - refetchOnMountOrArgChange: false, - refetchOnReconnect: false, - }, - mutations: {}, - provided: {}, - queries: {}, - subscriptions: {}, - } - expect(migratedSchema).toStrictEqual(expectedSchema) - }) }) diff --git a/src/redux/migrations.ts b/src/redux/migrations.ts index 2840b006623..163f3e5441c 100644 --- a/src/redux/migrations.ts +++ b/src/redux/migrations.ts @@ -10,7 +10,6 @@ import { LocalCurrencyCode } from 'src/localCurrency/consts' import { Screens } from 'src/navigator/Screens' import { Position } from 'src/positions/types' import { Recipient } from 'src/recipients/recipient' -import { transactionFeedV2Api } from 'src/transactions/api' import { Network, NetworkId, StandbyTransaction, TokenTransaction } from 'src/transactions/types' import { CiCoCurrency, Currency } from 'src/utils/currencies' import networkConfig from 'src/web3/networkConfig' @@ -1917,24 +1916,5 @@ export const migrations = { ...state, app: _.omit(state.app, 'numberVerified'), }), - 233: (state: any) => ({ - ...state, - [transactionFeedV2Api.reducerPath]: { - config: { - focused: true, - invalidationBehavior: 'delayed', - keepUnusedDataFor: 60, - middlewareRegistered: true, - online: true, - reducerPath: transactionFeedV2Api.reducerPath, - refetchOnFocus: false, - refetchOnMountOrArgChange: false, - refetchOnReconnect: false, - }, - mutations: {}, - provided: {}, - queries: {}, - subscriptions: {}, - }, - }), + 233: (state: any) => state, } diff --git a/src/redux/reducers.ts b/src/redux/reducers.ts index d882fa2cea9..0b7cb6ec9cd 100644 --- a/src/redux/reducers.ts +++ b/src/redux/reducers.ts @@ -1,64 +1,11 @@ -import { Action, combineReducers } from '@reduxjs/toolkit' -import { PersistState } from 'redux-persist' -import { Actions, ClearStoredAccountAction } from 'src/account/actions' -import { reducer as account } from 'src/account/reducer' -import { reducer as alert } from 'src/alert/reducer' -import { appReducer as app } from 'src/app/reducers' -import dappsReducer from 'src/dapps/slice' -import earnReducer from 'src/earn/slice' -import { reducer as fiatExchanges } from 'src/fiatExchanges/reducer' -import fiatConnectReducer from 'src/fiatconnect/slice' -import { homeReducer as home } from 'src/home/reducers' -import i18nReducer from 'src/i18n/slice' +import { type Action, combineReducers } from '@reduxjs/toolkit' +import { type PersistState } from 'redux-persist' +import { Actions, type ClearStoredAccountAction } from 'src/account/actions' import { reducer as identity } from 'src/identity/reducer' -import { reducer as imports } from 'src/import/reducer' -import jumpstartReducer from 'src/jumpstart/slice' -import keylessBackupReducer from 'src/keylessBackup/slice' -import { reducer as localCurrency } from 'src/localCurrency/reducer' -import { reducer as networkInfo } from 'src/networkInfo/reducer' -import nftsReducer from 'src/nfts/slice' -import pointsReducer from 'src/points/slice' -import positionsReducer from 'src/positions/slice' -import priceHistoryReducer from 'src/priceHistory/slice' -import { recipientsReducer as recipients } from 'src/recipients/reducer' -import { sendReducer as send } from 'src/send/reducers' -import swapReducer from 'src/swap/slice' -import tokenReducer from 'src/tokens/slice' -import { transactionFeedV2Api } from 'src/transactions/api' -import { reducer as transactions } from 'src/transactions/reducer' -import { reducer as walletConnect } from 'src/walletConnect/reducer' -import { reducer as web3 } from 'src/web3/reducer' +import { apiReducersList } from 'src/redux/apiReducersList' +import { reducersList } from 'src/redux/reducersList' -const appReducer = combineReducers({ - app, - i18n: i18nReducer, - networkInfo, - alert, - send, - home, - transactions, - web3, - identity, - account, - recipients, - localCurrency, - imports, - fiatExchanges, - walletConnect, - tokens: tokenReducer, - dapps: dappsReducer, - fiatConnect: fiatConnectReducer, - swap: swapReducer, - positions: positionsReducer, - keylessBackup: keylessBackupReducer, - nfts: nftsReducer, - priceHistory: priceHistoryReducer, - jumpstart: jumpstartReducer, - points: pointsReducer, - earn: earnReducer, - - [transactionFeedV2Api.reducerPath]: transactionFeedV2Api.reducer, -}) +const appReducer = combineReducers({ ...reducersList, ...apiReducersList }) const rootReducer = (state: RootState | undefined, action: Action): RootState => { if (action.type === Actions.CLEAR_STORED_ACCOUNT && state) { diff --git a/src/redux/reducersForSchemaGeneration.ts b/src/redux/reducersForSchemaGeneration.ts new file mode 100644 index 00000000000..964366963db --- /dev/null +++ b/src/redux/reducersForSchemaGeneration.ts @@ -0,0 +1,8 @@ +import { combineReducers } from 'redux' +import { PersistState } from 'redux-persist' +import { reducersList } from 'src/redux/reducersList' + +const reducerForSchemaGeneration = combineReducers(reducersList) +export type RootStateForSchemaGeneration = ReturnType & { + _persist: PersistState +} diff --git a/src/redux/reducersList.ts b/src/redux/reducersList.ts new file mode 100644 index 00000000000..efea1b99b02 --- /dev/null +++ b/src/redux/reducersList.ts @@ -0,0 +1,55 @@ +import { reducer as account } from 'src/account/reducer' +import { reducer as alert } from 'src/alert/reducer' +import { appReducer as app } from 'src/app/reducers' +import dappsReducer from 'src/dapps/slice' +import earnReducer from 'src/earn/slice' +import { reducer as fiatExchanges } from 'src/fiatExchanges/reducer' +import fiatConnectReducer from 'src/fiatconnect/slice' +import { homeReducer as home } from 'src/home/reducers' +import i18nReducer from 'src/i18n/slice' +import { reducer as identity } from 'src/identity/reducer' +import { reducer as imports } from 'src/import/reducer' +import jumpstartReducer from 'src/jumpstart/slice' +import keylessBackupReducer from 'src/keylessBackup/slice' +import { reducer as localCurrency } from 'src/localCurrency/reducer' +import { reducer as networkInfo } from 'src/networkInfo/reducer' +import nftsReducer from 'src/nfts/slice' +import pointsReducer from 'src/points/slice' +import positionsReducer from 'src/positions/slice' +import priceHistoryReducer from 'src/priceHistory/slice' +import { recipientsReducer as recipients } from 'src/recipients/reducer' +import { sendReducer as send } from 'src/send/reducers' +import swapReducer from 'src/swap/slice' +import tokenReducer from 'src/tokens/slice' +import { reducer as transactions } from 'src/transactions/reducer' +import { reducer as walletConnect } from 'src/walletConnect/reducer' +import { reducer as web3 } from 'src/web3/reducer' + +export const reducersList = { + app, + i18n: i18nReducer, + networkInfo, + alert, + send, + home, + transactions, + web3, + identity, + account, + recipients, + localCurrency, + imports, + fiatExchanges, + walletConnect, + tokens: tokenReducer, + dapps: dappsReducer, + fiatConnect: fiatConnectReducer, + swap: swapReducer, + positions: positionsReducer, + keylessBackup: keylessBackupReducer, + nfts: nftsReducer, + priceHistory: priceHistoryReducer, + jumpstart: jumpstartReducer, + points: pointsReducer, + earn: earnReducer, +} as const diff --git a/src/redux/store.test.ts b/src/redux/store.test.ts index a79ce5888da..3f8d1b6f129 100644 --- a/src/redux/store.test.ts +++ b/src/redux/store.test.ts @@ -1,7 +1,9 @@ import Ajv from 'ajv' import { spawn, takeEvery } from 'redux-saga/effects' +import { ApiReducersKeys } from 'src/redux/apiReducersList' import * as createMigrateModule from 'src/redux/createMigrate' import { migrations } from 'src/redux/migrations' +import { RootState } from 'src/redux/reducers' import { rootSaga } from 'src/redux/sagas' import { _persistConfig, setupStore } from 'src/redux/store' import * as accountCheckerModule from 'src/utils/accountChecker' @@ -25,6 +27,16 @@ const resetStateOnInvalidStoredAccount = jest.spyOn( const loggerErrorSpy = jest.spyOn(Logger, 'error') +const getNonApiReducers = >(state: RootState): R => { + const apiReducersKeys: string[] = ['transactionFeedV2Api'] satisfies ApiReducersKeys[] + return Object.entries(state).reduce((acc, [reducerKey, value]) => { + const key = reducerKey as keyof R + if (apiReducersKeys.includes(reducerKey)) return acc + acc[key] = value as unknown as any + return acc + }, {} as R) +} + beforeEach(() => { jest.clearAllMocks() // For some reason createMigrate.mockRestore doesn't work, so instead we manually reset it to the original implementation @@ -81,7 +93,7 @@ describe('store state', () => { }) }) - const data = store.getState() + const data = getNonApiReducers(store.getState()) const ajv = new Ajv({ allErrors: true, allowUnionTypes: true }) const schema = require('test/RootStateSchema.json') @@ -326,23 +338,6 @@ describe('store state', () => { "loading": false, "tokenBalances": {}, }, - "transactionFeedV2Api": { - "config": { - "focused": true, - "invalidationBehavior": "delayed", - "keepUnusedDataFor": 60, - "middlewareRegistered": true, - "online": true, - "reducerPath": "transactionFeedV2Api", - "refetchOnFocus": false, - "refetchOnMountOrArgChange": false, - "refetchOnReconnect": false, - }, - "mutations": {}, - "provided": {}, - "queries": {}, - "subscriptions": {}, - }, "transactions": { "standbyTransactions": [], "transactionsByNetworkId": {}, diff --git a/src/redux/store.ts b/src/redux/store.ts index 1b67c158d1b..b7219b101c3 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -26,7 +26,14 @@ const persistConfig: PersistConfig = { version: 233, keyPrefix: `reduxStore-`, // the redux-persist default is `persist:` which doesn't work with some file systems. storage: FSStorage(), - blacklist: ['networkInfo', 'alert', 'imports', 'keylessBackup', 'jumpstart'], + blacklist: [ + 'networkInfo', + 'alert', + 'imports', + 'keylessBackup', + 'jumpstart', + transactionFeedV2Api.reducerPath, + ], stateReconciler: autoMergeLevel2, migrate: async (...args) => { const migrate = createMigrate(migrations) diff --git a/test/RootStateSchema.json b/test/RootStateSchema.json index db51d75af91..550615e97d8 100644 --- a/test/RootStateSchema.json +++ b/test/RootStateSchema.json @@ -859,62 +859,6 @@ ], "type": "object" }, - "ConfigState<\"transactionFeedV2Api\">": { - "additionalProperties": false, - "properties": { - "focused": { - "type": "boolean" - }, - "invalidationBehavior": { - "enum": [ - "delayed", - "immediately" - ], - "type": "string" - }, - "keepUnusedDataFor": { - "type": "number" - }, - "middlewareRegistered": { - "enum": [ - "conflict", - false, - true - ] - }, - "online": { - "type": "boolean" - }, - "reducerPath": { - "const": "transactionFeedV2Api", - "type": "string" - }, - "refetchOnFocus": { - "type": "boolean" - }, - "refetchOnMountOrArgChange": { - "type": [ - "number", - "boolean" - ] - }, - "refetchOnReconnect": { - "type": "boolean" - } - }, - "required": [ - "focused", - "invalidationBehavior", - "keepUnusedDataFor", - "middlewareRegistered", - "online", - "reducerPath", - "refetchOnFocus", - "refetchOnMountOrArgChange", - "refetchOnReconnect" - ], - "type": "object" - }, "ContactRecipient": { "anyOf": [ { @@ -2000,10 +1944,6 @@ ], "type": "number" }, - "InvalidationState": { - "additionalProperties": false, - "type": "object" - }, "KeylessBackupDeleteStatus": { "enum": [ "Completed", @@ -7095,148 +7035,6 @@ }, "type": "object" }, - "{status:QueryStatus.fulfilled;error:undefined;originalArgs:unknown;requestId:string;endpointName:string;startedTimeStamp:number;data:unknown;fulfilledTimeStamp:number;}": { - "additionalProperties": false, - "properties": { - "data": { - "description": "The received data from the query" - }, - "endpointName": { - "description": "The name of the endpoint associated with the query", - "type": "string" - }, - "fulfilledTimeStamp": { - "description": "Time that the latest query was fulfilled", - "type": "number" - }, - "originalArgs": { - "description": "The argument originally passed into the hook or `initiate` action call" - }, - "requestId": { - "description": "A unique ID associated with the request", - "type": "string" - }, - "startedTimeStamp": { - "description": "Time that the latest query started", - "type": "number" - }, - "status": { - "const": "fulfilled", - "type": "string" - } - }, - "required": [ - "data", - "endpointName", - "fulfilledTimeStamp", - "originalArgs", - "requestId", - "startedTimeStamp", - "status" - ], - "type": "object" - }, - "{status:QueryStatus.pending;originalArgs:unknown;requestId:string;data?:unknown;error?:unknown;endpointName:string;startedTimeStamp:number;fulfilledTimeStamp?:number|undefined;}": { - "additionalProperties": false, - "properties": { - "data": { - "description": "The received data from the query" - }, - "endpointName": { - "description": "The name of the endpoint associated with the query", - "type": "string" - }, - "error": { - "description": "The received error if applicable" - }, - "fulfilledTimeStamp": { - "description": "Time that the latest query was fulfilled", - "type": "number" - }, - "originalArgs": { - "description": "The argument originally passed into the hook or `initiate` action call" - }, - "requestId": { - "description": "A unique ID associated with the request", - "type": "string" - }, - "startedTimeStamp": { - "description": "Time that the latest query started", - "type": "number" - }, - "status": { - "const": "pending", - "type": "string" - } - }, - "required": [ - "endpointName", - "originalArgs", - "requestId", - "startedTimeStamp", - "status" - ], - "type": "object" - }, - "{status:QueryStatus.rejected;data?:unknown;fulfilledTimeStamp?:number|undefined;originalArgs:unknown;requestId:string;endpointName:string;startedTimeStamp:number;error:unknown;}": { - "additionalProperties": false, - "properties": { - "data": { - "description": "The received data from the query" - }, - "endpointName": { - "description": "The name of the endpoint associated with the query", - "type": "string" - }, - "error": { - "description": "The received error if applicable" - }, - "fulfilledTimeStamp": { - "description": "Time that the latest query was fulfilled", - "type": "number" - }, - "originalArgs": { - "description": "The argument originally passed into the hook or `initiate` action call" - }, - "requestId": { - "description": "A unique ID associated with the request", - "type": "string" - }, - "startedTimeStamp": { - "description": "Time that the latest query started", - "type": "number" - }, - "status": { - "const": "rejected", - "type": "string" - } - }, - "required": [ - "endpointName", - "error", - "originalArgs", - "requestId", - "startedTimeStamp", - "status" - ], - "type": "object" - }, - "{status:QueryStatus.uninitialized;originalArgs?:undefined;data?:undefined;error?:undefined;requestId?:undefined;endpointName?:string|undefined;startedTimeStamp?:undefined;fulfilledTimeStamp?:undefined;}": { - "additionalProperties": false, - "properties": { - "endpointName": { - "type": "string" - }, - "status": { - "const": "uninitialized", - "type": "string" - } - }, - "required": [ - "status" - ], - "type": "object" - }, "{swap?:boolean|undefined;\"create-wallet\"?:boolean|undefined;\"create-live-link\"?:boolean|undefined;\"deposit-earn\"?:boolean|undefined;}": { "additionalProperties": false, "properties": { @@ -7418,188 +7216,6 @@ "tokens": { "$ref": "#/definitions/State_15" }, - "transactionFeedV2Api": { - "additionalProperties": false, - "properties": { - "config": { - "$ref": "#/definitions/ConfigState<\"transactionFeedV2Api\">" - }, - "mutations": { - "additionalProperties": { - "anyOf": [ - { - "additionalProperties": false, - "properties": { - "endpointName": { - "type": "string" - }, - "status": { - "const": "uninitialized", - "type": "string" - } - }, - "required": [ - "status" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "data": {}, - "endpointName": { - "type": "string" - }, - "error": {}, - "fulfilledTimeStamp": { - "type": "number" - }, - "requestId": { - "type": "string" - }, - "startedTimeStamp": { - "type": "number" - }, - "status": { - "const": "fulfilled", - "type": "string" - } - }, - "required": [ - "data", - "endpointName", - "fulfilledTimeStamp", - "requestId", - "startedTimeStamp", - "status" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "data": {}, - "endpointName": { - "type": "string" - }, - "error": {}, - "fulfilledTimeStamp": { - "type": "number" - }, - "requestId": { - "type": "string" - }, - "startedTimeStamp": { - "type": "number" - }, - "status": { - "const": "pending", - "type": "string" - } - }, - "required": [ - "endpointName", - "requestId", - "startedTimeStamp", - "status" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "data": {}, - "endpointName": { - "type": "string" - }, - "error": {}, - "fulfilledTimeStamp": { - "type": "number" - }, - "requestId": { - "type": "string" - }, - "startedTimeStamp": { - "type": "number" - }, - "status": { - "const": "rejected", - "type": "string" - } - }, - "required": [ - "endpointName", - "error", - "requestId", - "startedTimeStamp", - "status" - ], - "type": "object" - } - ] - }, - "type": "object" - }, - "provided": { - "$ref": "#/definitions/InvalidationState" - }, - "queries": { - "additionalProperties": { - "anyOf": [ - { - "$ref": "#/definitions/{status:QueryStatus.uninitialized;originalArgs?:undefined;data?:undefined;error?:undefined;requestId?:undefined;endpointName?:string|undefined;startedTimeStamp?:undefined;fulfilledTimeStamp?:undefined;}" - }, - { - "$ref": "#/definitions/{status:QueryStatus.fulfilled;error:undefined;originalArgs:unknown;requestId:string;endpointName:string;startedTimeStamp:number;data:unknown;fulfilledTimeStamp:number;}" - }, - { - "$ref": "#/definitions/{status:QueryStatus.pending;originalArgs:unknown;requestId:string;data?:unknown;error?:unknown;endpointName:string;startedTimeStamp:number;fulfilledTimeStamp?:number|undefined;}" - }, - { - "$ref": "#/definitions/{status:QueryStatus.rejected;data?:unknown;fulfilledTimeStamp?:number|undefined;originalArgs:unknown;requestId:string;endpointName:string;startedTimeStamp:number;error:unknown;}" - } - ] - }, - "type": "object" - }, - "subscriptions": { - "additionalProperties": { - "additionalProperties": { - "additionalProperties": false, - "properties": { - "pollingInterval": { - "description": "How frequently to automatically re-fetch data (in milliseconds). Defaults to `0` (off).", - "type": "number" - }, - "refetchOnFocus": { - "description": "Defaults to `false`. This setting allows you to control whether RTK Query will try to refetch all subscribed queries after the application window regains focus.\n\nIf you specify this option alongside `skip: true`, this **will not be evaluated** until `skip` is false.\n\nNote: requires [`setupListeners`](./setupListeners) to have been called.", - "type": "boolean" - }, - "refetchOnReconnect": { - "description": "Defaults to `false`. This setting allows you to control whether RTK Query will try to refetch all subscribed queries after regaining a network connection.\n\nIf you specify this option alongside `skip: true`, this **will not be evaluated** until `skip` is false.\n\nNote: requires [`setupListeners`](./setupListeners) to have been called.", - "type": "boolean" - }, - "skipPollingIfUnfocused": { - "description": "Defaults to 'false'. This setting allows you to control whether RTK Query will continue polling if the window is not focused.\n\nIf pollingInterval is not set or set to 0, this **will not be evaluated** until pollingInterval is greater than 0.\n\nNote: requires [`setupListeners`](./setupListeners) to have been called.", - "type": "boolean" - } - }, - "type": "object" - }, - "type": "object" - }, - "type": "object" - } - }, - "required": [ - "config", - "mutations", - "provided", - "queries", - "subscriptions" - ], - "type": "object" - }, "transactions": { "$ref": "#/definitions/State_6" }, @@ -7635,7 +7251,6 @@ "send", "swap", "tokens", - "transactionFeedV2Api", "transactions", "walletConnect", "web3" diff --git a/test/schema.test.ts b/test/schema.test.ts index 22fb9b1ec84..8adf067a247 100644 --- a/test/schema.test.ts +++ b/test/schema.test.ts @@ -9,7 +9,7 @@ describe(getLatestSchema, () => { it('validates against the RootState schema', async () => { const data = getLatestSchema() - const ajv = new Ajv({ allErrors: true }) + const ajv = new Ajv({ allErrors: true, allowUnionTypes: true }) const schema = require('./RootStateSchema.json') const validate = ajv.compile(schema) const isValid = validate(data) diff --git a/test/schemas.ts b/test/schemas.ts index b634c0096bc..8d5e4dbef28 100644 --- a/test/schemas.ts +++ b/test/schemas.ts @@ -11,7 +11,6 @@ import { KeylessBackupDeleteStatus, KeylessBackupStatus } from 'src/keylessBacku import { LocalCurrencyCode } from 'src/localCurrency/consts' import { updateCachedQuoteParams } from 'src/redux/migrations' import { RootState } from 'src/redux/store' -import { transactionFeedV2Api } from 'src/transactions/api' import { Network, NetworkId, StandbyTransaction, TokenTransaction } from 'src/transactions/types' import { CiCoCurrency, Currency } from 'src/utils/currencies' import networkConfig from 'src/web3/networkConfig' @@ -3540,23 +3539,6 @@ export const v233Schema = { ...v232Schema._persist, version: 233, }, - [transactionFeedV2Api.reducerPath]: { - config: { - focused: true, - invalidationBehavior: 'delayed', - keepUnusedDataFor: 60, - middlewareRegistered: true, - online: true, - reducerPath: transactionFeedV2Api.reducerPath, - refetchOnFocus: false, - refetchOnMountOrArgChange: false, - refetchOnReconnect: false, - }, - mutations: {}, - provided: {}, - queries: {}, - subscriptions: {}, - }, } export function getLatestSchema(): Partial { From 22984cb9b6559df529b7fdd3278ff095757e93a7 Mon Sep 17 00:00:00 2001 From: Myroslav Sviderok Date: Mon, 7 Oct 2024 11:57:33 +0300 Subject: [PATCH 04/16] Move api middlewares to separate variable --- src/redux/apiReducersList.ts | 4 +++- src/redux/store.ts | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/redux/apiReducersList.ts b/src/redux/apiReducersList.ts index b1a03d374de..8c5126c9be0 100644 --- a/src/redux/apiReducersList.ts +++ b/src/redux/apiReducersList.ts @@ -1,7 +1,9 @@ import { transactionFeedV2Api } from 'src/transactions/api' +export type ApiReducersKeys = keyof typeof apiReducersList + export const apiReducersList = { [transactionFeedV2Api.reducerPath]: transactionFeedV2Api.reducer, } as const -export type ApiReducersKeys = keyof typeof apiReducersList +export const apiMiddlewares = [transactionFeedV2Api.middleware] diff --git a/src/redux/store.ts b/src/redux/store.ts index b7219b101c3..866dfe24534 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -7,6 +7,7 @@ import autoMergeLevel2 from 'redux-persist/lib/stateReconciler/autoMergeLevel2' import createSagaMiddleware from 'redux-saga' import AppAnalytics from 'src/analytics/AppAnalytics' import { PerformanceEvents } from 'src/analytics/Events' +import { apiMiddlewares } from 'src/redux/apiReducersList' import { createMigrate } from 'src/redux/createMigrate' import { migrations } from 'src/redux/migrations' import rootReducer, { RootState as ReducersRootState } from 'src/redux/reducers' @@ -113,7 +114,7 @@ export const setupStore = (initialState?: ReducersRootState, config = persistCon }, }) - const middlewares: Middleware[] = [sagaMiddleware, transactionFeedV2Api.middleware] + const middlewares: Middleware[] = [sagaMiddleware, ...apiMiddlewares] if (__DEV__ && !process.env.JEST_WORKER_ID) { const createDebugger = require('redux-flipper').default From 48d50030e9af536846f0f798959adac25b90c37c Mon Sep 17 00:00:00 2001 From: Myroslav Sviderok Date: Mon, 7 Oct 2024 12:23:35 +0300 Subject: [PATCH 05/16] fix tests --- src/swap/SwapScreen.test.tsx | 6 ++++++ test/utils.ts | 3 +++ 2 files changed, 9 insertions(+) diff --git a/src/swap/SwapScreen.test.tsx b/src/swap/SwapScreen.test.tsx index 038fa02f8b6..31545a018f2 100644 --- a/src/swap/SwapScreen.test.tsx +++ b/src/swap/SwapScreen.test.tsx @@ -1328,6 +1328,9 @@ describe('SwapScreen', () => { status: 'started', }, }, + + // as per test/utils.ts, line 105 + transactionFeedV2Api: undefined, }) update( @@ -1373,6 +1376,9 @@ describe('SwapScreen', () => { status: 'error', }, }, + + // as per test/utils.ts, line 105 + transactionFeedV2Api: undefined, }) update( diff --git a/test/utils.ts b/test/utils.ts index 2abeba1f497..a592261296b 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -101,6 +101,9 @@ export function getMockStoreData(overrides: RecursivePartial = {}): R ...defaultSchema, ...contactMappingData, ...recipientData, + + // ignore api reducers that are managed by RTK-Query library itself + transactionFeedV2Api: undefined, } // Apply overrides. Note: only merges one level deep From 0fa8fc54343b4a27f64b78dea60bc428d8455bed Mon Sep 17 00:00:00 2001 From: Myroslav Sviderok Date: Mon, 7 Oct 2024 15:53:45 +0300 Subject: [PATCH 06/16] ingnore new file for schema generation --- knip.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/knip.ts b/knip.ts index a98c0772e79..fb3ef278f92 100644 --- a/knip.ts +++ b/knip.ts @@ -33,7 +33,11 @@ const config: KnipConfig = { '@types/jest', 'husky', ], - ignore: ['src/utils/inputValidation.ts', 'src/utils/country.json'], + ignore: [ + 'src/utils/inputValidation.ts', + 'src/utils/country.json', + 'src/redux/reducersForSchemaGeneration.ts', + ], } export default config From 24b632169ec83a6b8b997c54b95307703bbf6aca Mon Sep 17 00:00:00 2001 From: Myroslav Sviderok Date: Tue, 8 Oct 2024 14:02:52 +0300 Subject: [PATCH 07/16] Add tests --- src/transactions/api.test.tsx | 181 +++++++++++++++++++++++++++++ src/transactions/api.ts | 4 +- src/transactions/apiTestHelpers.ts | 45 +++++++ 3 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 src/transactions/api.test.tsx create mode 100644 src/transactions/apiTestHelpers.ts diff --git a/src/transactions/api.test.tsx b/src/transactions/api.test.tsx new file mode 100644 index 00000000000..e82cc6d3c4a --- /dev/null +++ b/src/transactions/api.test.tsx @@ -0,0 +1,181 @@ +import { type FetchBaseQueryError } from '@reduxjs/toolkit/query' +import { renderHook, waitFor } from '@testing-library/react-native' +import fetchMock from 'jest-fetch-mock' +import React from 'react' +import { Provider } from 'react-redux' +import { reducersList } from 'src/redux/reducersList' +import { + transactionFeedV2Api, + TransactionFeedV2Response, + useTransactionFeedV2Query, +} from 'src/transactions/api' +import { setupApiStore } from 'src/transactions/apiTestHelpers' +import { type TokenTransaction } from 'src/transactions/types' +import networkConfig from 'src/web3/networkConfig' + +function wrapper({ children }: { children: React.ReactNode }) { + const storeRef = setupApiStore(transactionFeedV2Api, {}, reducersList) + return {children} +} + +beforeEach(() => { + fetchMock.resetMocks() +}) + +describe('API Slice of Transactions Feed V2', () => { + describe('endpoint', () => { + it('request is correct', async () => { + const storeRef = setupApiStore(transactionFeedV2Api, {}, reducersList) + + const address = '0x00' + const endCursor = 0 + await storeRef.store.dispatch( + transactionFeedV2Api.endpoints.transactionFeedV2.initiate({ address, endCursor }) + ) + + const { method, headers, url } = fetchMock.mock.calls[0][0] as Request + const fullUrl = `${networkConfig.blockchainApiUrl}/wallet/${address}/transactions?endCursor=${endCursor}` + const accept = headers.get('Accept') + expect(fetchMock).toBeCalledTimes(1) + expect(method).toBe('GET') + expect(url).toBe(fullUrl) + expect(accept).toBe('application/json') + }) + + it('successful response', async () => { + const storeRef = setupApiStore(transactionFeedV2Api, {}, reducersList) + const responseData: TransactionFeedV2Response = { + transactions: [{ transactionHash: '0x00' } as TokenTransaction], + pageInfo: { hasNextPage: false }, + } + fetchMock.mockResponse(JSON.stringify(responseData)) + + const address = '0x00' + const endCursor = 0 + const { data, status, isSuccess } = await storeRef.store.dispatch( + transactionFeedV2Api.endpoints.transactionFeedV2.initiate({ address, endCursor }) + ) + + expect(status).toBe('fulfilled') + expect(isSuccess).toBe(true) + expect(data).toStrictEqual(responseData) + expect(fetchMock).toBeCalledTimes(1) + }) + + it('unsuccessful response', async () => { + const storeRef = setupApiStore(transactionFeedV2Api, {}, reducersList) + fetchMock.mockReject(new Error('Internal Server Error')) + + const address = '0x00' + const endCursor = 0 + const { error, isError, status } = await storeRef.store.dispatch( + transactionFeedV2Api.endpoints.transactionFeedV2.initiate({ address, endCursor }) + ) + const typedError = error as FetchBaseQueryError + + expect(status).toBe('rejected') + expect(isError).toBe(true) + expect(typedError.status).toBe('FETCH_ERROR') + expect(typedError.status === 'FETCH_ERROR' && typedError.error).toBe( + 'Error: Internal Server Error' + ) + }) + }) + + describe('generated hooks', () => { + describe('useTransactionFeedV2Query', () => { + it('request successful', async () => { + const responseData: TransactionFeedV2Response = { + transactions: [{ transactionHash: '0x00' } as TokenTransaction], + pageInfo: { hasNextPage: false }, + } + fetchMock.mockResponse(JSON.stringify(responseData)) + + const address = '0x00' + const endCursor = 0 + const { result } = renderHook(() => useTransactionFeedV2Query({ address, endCursor }), { + wrapper, + }) + + // Response started and is ongoing + const initialResponse = result.current + expect(initialResponse.data).toBeUndefined() + expect(initialResponse.isLoading).toBe(true) + expect(initialResponse.isFetching).toBe(true) + + // Response finished successfully + await waitFor(() => { + const nextResponse = result.current + expect(nextResponse.data).not.toBeUndefined() + expect(nextResponse.isLoading).toBe(false) + expect(nextResponse.isFetching).toBe(false) + expect(nextResponse.isSuccess).toBe(true) + }) + }) + + it('request failed', async () => { + fetchMock.mockReject(new Error('Internal Server Error')) + + const address = '0x00' + const endCursor = 0 + const { result } = renderHook(() => useTransactionFeedV2Query({ address, endCursor }), { + wrapper, + }) + + // Response started and is ongoing + const initialResponse = result.current + expect(initialResponse.data).toBeUndefined() + expect(initialResponse.isLoading).toBe(true) + expect(initialResponse.isFetching).toBe(true) + + // Response failed + await waitFor(() => { + const nextResponse = result.current + const error = nextResponse.error as FetchBaseQueryError + expect(nextResponse.data).toBeUndefined() + expect(nextResponse.isLoading).toBe(false) + expect(nextResponse.isFetching).toBe(false) + expect(nextResponse.isSuccess).toBe(false) + expect(nextResponse.isError).toBe(true) + expect(error.status === 'FETCH_ERROR' && error.error).toBe('Error: Internal Server Error') + }) + }) + + it('using hook twice only fires a single request', async () => { + const storeRef = setupApiStore(transactionFeedV2Api, {}, reducersList) + const responseData: TransactionFeedV2Response = { + transactions: [{ transactionHash: '0x00' } as TokenTransaction], + pageInfo: { hasNextPage: false }, + } + fetchMock.mockResponse(JSON.stringify(responseData)) + + const address = '0x00' + const endCursor = 0 + + // First call fetches the data and stores it in cache + const { result } = renderHook( + () => storeRef.api.useTransactionFeedV2Query({ address, endCursor }), + { wrapper } + ) + + const initialResponse = result.current + expect(initialResponse.isLoading).toBe(true) + + await waitFor(() => { + const nextResponse = result.current + expect(nextResponse.data).not.toBeUndefined() + }) + + // Usage of hook with the same args returns data from cache and doesn't trigger another request + renderHook(() => storeRef.api.useTransactionFeedV2Query({ address, endCursor }), { + wrapper, + }) + renderHook(() => storeRef.api.useTransactionFeedV2Query({ address, endCursor }), { + wrapper, + }) + + expect(fetchMock).toBeCalledTimes(1) + }) + }) + }) +}) diff --git a/src/transactions/api.ts b/src/transactions/api.ts index 7bd0dca99c8..7d622628d92 100644 --- a/src/transactions/api.ts +++ b/src/transactions/api.ts @@ -2,7 +2,7 @@ import { createApi } from '@reduxjs/toolkit/query/react' import { baseQuery, isRehydrateAction } from 'src/redux/api' import type { TokenTransaction } from 'src/transactions/types' -type TransactionFeedV2Response = { +export type TransactionFeedV2Response = { transactions: TokenTransaction[] pageInfo: { hasNextPage: boolean @@ -29,3 +29,5 @@ export const transactionFeedV2Api = createApi({ } }, }) + +export const { useTransactionFeedV2Query } = transactionFeedV2Api diff --git a/src/transactions/apiTestHelpers.ts b/src/transactions/apiTestHelpers.ts new file mode 100644 index 00000000000..4ec9fa545db --- /dev/null +++ b/src/transactions/apiTestHelpers.ts @@ -0,0 +1,45 @@ +import type { EnhancedStore, Middleware, Reducer, UnknownAction } from '@reduxjs/toolkit' +import { combineReducers, configureStore } from '@reduxjs/toolkit' +import { ApiReducersKeys } from 'src/redux/apiReducersList' +import { RootState } from 'src/redux/reducers' +import { RecursivePartial } from 'test/utils' + +// https://medium.com/@johnmcdowell0801/testing-rtk-query-with-jest-cdfa5aaf3dc1 +export function setupApiStore< + A extends { + reducer: Reducer + reducerPath: string + middleware: Middleware + util: { resetApiState(): any } + }, + Preloaded extends RecursivePartial>, + R extends Record> = Record, +>(api: A, preloadedState: Preloaded, extraReducers?: R) { + const getStore = () => + configureStore({ + preloadedState, + reducer: combineReducers({ + [api.reducerPath]: api.reducer, + ...extraReducers, + }), + middleware: (gdm) => + gdm({ serializableCheck: false, immutableCheck: false }).concat(api.middleware), + }) + + type StoreType = EnhancedStore< + { + api: ReturnType + } & { + [K in keyof R]: ReturnType + }, + UnknownAction, + ReturnType extends EnhancedStore ? M : never + > + + const initialStore = getStore() as StoreType + const refObj = { api, store: initialStore } + const store = getStore() as StoreType + refObj.store = store + + return refObj +} From f976becf7014230ef9c22ef102115106df31fa21 Mon Sep 17 00:00:00 2001 From: Myroslav Sviderok Date: Wed, 9 Oct 2024 10:38:42 +0300 Subject: [PATCH 08/16] fix as per review comments --- src/redux/store.test.ts | 6 +++--- src/transactions/api.ts | 10 +--------- src/transactions/apiTestHelpers.ts | 17 ++++++++++++++--- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/src/redux/store.test.ts b/src/redux/store.test.ts index 3f8d1b6f129..2edb05cb32c 100644 --- a/src/redux/store.test.ts +++ b/src/redux/store.test.ts @@ -1,6 +1,6 @@ import Ajv from 'ajv' import { spawn, takeEvery } from 'redux-saga/effects' -import { ApiReducersKeys } from 'src/redux/apiReducersList' +import { ApiReducersKeys, apiReducersList } from 'src/redux/apiReducersList' import * as createMigrateModule from 'src/redux/createMigrate' import { migrations } from 'src/redux/migrations' import { RootState } from 'src/redux/reducers' @@ -27,8 +27,8 @@ const resetStateOnInvalidStoredAccount = jest.spyOn( const loggerErrorSpy = jest.spyOn(Logger, 'error') -const getNonApiReducers = >(state: RootState): R => { - const apiReducersKeys: string[] = ['transactionFeedV2Api'] satisfies ApiReducersKeys[] +function getNonApiReducers>(state: RootState): R { + const apiReducersKeys: string[] = Object.keys(apiReducersList) return Object.entries(state).reduce((acc, [reducerKey, value]) => { const key = reducerKey as keyof R if (apiReducersKeys.includes(reducerKey)) return acc diff --git a/src/transactions/api.ts b/src/transactions/api.ts index 7d622628d92..01e944d6234 100644 --- a/src/transactions/api.ts +++ b/src/transactions/api.ts @@ -1,5 +1,5 @@ import { createApi } from '@reduxjs/toolkit/query/react' -import { baseQuery, isRehydrateAction } from 'src/redux/api' +import { baseQuery } from 'src/redux/api' import type { TokenTransaction } from 'src/transactions/types' export type TransactionFeedV2Response = { @@ -20,14 +20,6 @@ export const transactionFeedV2Api = createApi({ query: ({ address, endCursor }) => `/wallet/${address}/transactions?endCursor=${endCursor}`, }), }), - extractRehydrationInfo: (action, { reducerPath }): any => { - if (isRehydrateAction(action)) { - /** - * Even though payload is types as RootState - redux-persist can evaluate it as undefined. - */ - return action.payload?.[reducerPath] - } - }, }) export const { useTransactionFeedV2Query } = transactionFeedV2Api diff --git a/src/transactions/apiTestHelpers.ts b/src/transactions/apiTestHelpers.ts index 4ec9fa545db..3727efe1343 100644 --- a/src/transactions/apiTestHelpers.ts +++ b/src/transactions/apiTestHelpers.ts @@ -4,7 +4,14 @@ import { ApiReducersKeys } from 'src/redux/apiReducersList' import { RootState } from 'src/redux/reducers' import { RecursivePartial } from 'test/utils' -// https://medium.com/@johnmcdowell0801/testing-rtk-query-with-jest-cdfa5aaf3dc1 +/** + * This function is taken from the Redux team. It creates a testable store that is compatible with RTK-Query. + * It is slightly modified to also include the preloaded state. + * https://github.com/reduxjs/redux-toolkit/blob/e7540a5594b0d880037f2ff41a83a32c629d3117/packages/toolkit/src/tests/utils/helpers.tsx#L186 + * + * For more info on why this is needed and how it works - here's an article that answers some of the questions: + * https://medium.com/@johnmcdowell0801/testing-rtk-query-with-jest-cdfa5aaf3dc1 + */ export function setupApiStore< A extends { reducer: Reducer @@ -22,8 +29,12 @@ export function setupApiStore< [api.reducerPath]: api.reducer, ...extraReducers, }), - middleware: (gdm) => - gdm({ serializableCheck: false, immutableCheck: false }).concat(api.middleware), + middleware: (getDefaultMiddleware) => { + return getDefaultMiddleware({ + serializableCheck: false, + immutableCheck: false, + }).concat(api.middleware) + }, }) type StoreType = EnhancedStore< From 07c4f482849108e929ffb808c33c2e8c58987697 Mon Sep 17 00:00:00 2001 From: Myroslav Sviderok Date: Wed, 9 Oct 2024 10:50:15 +0300 Subject: [PATCH 09/16] remove test file for api reducer --- src/transactions/api.test.tsx | 181 ---------------------------------- 1 file changed, 181 deletions(-) delete mode 100644 src/transactions/api.test.tsx diff --git a/src/transactions/api.test.tsx b/src/transactions/api.test.tsx deleted file mode 100644 index e82cc6d3c4a..00000000000 --- a/src/transactions/api.test.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import { type FetchBaseQueryError } from '@reduxjs/toolkit/query' -import { renderHook, waitFor } from '@testing-library/react-native' -import fetchMock from 'jest-fetch-mock' -import React from 'react' -import { Provider } from 'react-redux' -import { reducersList } from 'src/redux/reducersList' -import { - transactionFeedV2Api, - TransactionFeedV2Response, - useTransactionFeedV2Query, -} from 'src/transactions/api' -import { setupApiStore } from 'src/transactions/apiTestHelpers' -import { type TokenTransaction } from 'src/transactions/types' -import networkConfig from 'src/web3/networkConfig' - -function wrapper({ children }: { children: React.ReactNode }) { - const storeRef = setupApiStore(transactionFeedV2Api, {}, reducersList) - return {children} -} - -beforeEach(() => { - fetchMock.resetMocks() -}) - -describe('API Slice of Transactions Feed V2', () => { - describe('endpoint', () => { - it('request is correct', async () => { - const storeRef = setupApiStore(transactionFeedV2Api, {}, reducersList) - - const address = '0x00' - const endCursor = 0 - await storeRef.store.dispatch( - transactionFeedV2Api.endpoints.transactionFeedV2.initiate({ address, endCursor }) - ) - - const { method, headers, url } = fetchMock.mock.calls[0][0] as Request - const fullUrl = `${networkConfig.blockchainApiUrl}/wallet/${address}/transactions?endCursor=${endCursor}` - const accept = headers.get('Accept') - expect(fetchMock).toBeCalledTimes(1) - expect(method).toBe('GET') - expect(url).toBe(fullUrl) - expect(accept).toBe('application/json') - }) - - it('successful response', async () => { - const storeRef = setupApiStore(transactionFeedV2Api, {}, reducersList) - const responseData: TransactionFeedV2Response = { - transactions: [{ transactionHash: '0x00' } as TokenTransaction], - pageInfo: { hasNextPage: false }, - } - fetchMock.mockResponse(JSON.stringify(responseData)) - - const address = '0x00' - const endCursor = 0 - const { data, status, isSuccess } = await storeRef.store.dispatch( - transactionFeedV2Api.endpoints.transactionFeedV2.initiate({ address, endCursor }) - ) - - expect(status).toBe('fulfilled') - expect(isSuccess).toBe(true) - expect(data).toStrictEqual(responseData) - expect(fetchMock).toBeCalledTimes(1) - }) - - it('unsuccessful response', async () => { - const storeRef = setupApiStore(transactionFeedV2Api, {}, reducersList) - fetchMock.mockReject(new Error('Internal Server Error')) - - const address = '0x00' - const endCursor = 0 - const { error, isError, status } = await storeRef.store.dispatch( - transactionFeedV2Api.endpoints.transactionFeedV2.initiate({ address, endCursor }) - ) - const typedError = error as FetchBaseQueryError - - expect(status).toBe('rejected') - expect(isError).toBe(true) - expect(typedError.status).toBe('FETCH_ERROR') - expect(typedError.status === 'FETCH_ERROR' && typedError.error).toBe( - 'Error: Internal Server Error' - ) - }) - }) - - describe('generated hooks', () => { - describe('useTransactionFeedV2Query', () => { - it('request successful', async () => { - const responseData: TransactionFeedV2Response = { - transactions: [{ transactionHash: '0x00' } as TokenTransaction], - pageInfo: { hasNextPage: false }, - } - fetchMock.mockResponse(JSON.stringify(responseData)) - - const address = '0x00' - const endCursor = 0 - const { result } = renderHook(() => useTransactionFeedV2Query({ address, endCursor }), { - wrapper, - }) - - // Response started and is ongoing - const initialResponse = result.current - expect(initialResponse.data).toBeUndefined() - expect(initialResponse.isLoading).toBe(true) - expect(initialResponse.isFetching).toBe(true) - - // Response finished successfully - await waitFor(() => { - const nextResponse = result.current - expect(nextResponse.data).not.toBeUndefined() - expect(nextResponse.isLoading).toBe(false) - expect(nextResponse.isFetching).toBe(false) - expect(nextResponse.isSuccess).toBe(true) - }) - }) - - it('request failed', async () => { - fetchMock.mockReject(new Error('Internal Server Error')) - - const address = '0x00' - const endCursor = 0 - const { result } = renderHook(() => useTransactionFeedV2Query({ address, endCursor }), { - wrapper, - }) - - // Response started and is ongoing - const initialResponse = result.current - expect(initialResponse.data).toBeUndefined() - expect(initialResponse.isLoading).toBe(true) - expect(initialResponse.isFetching).toBe(true) - - // Response failed - await waitFor(() => { - const nextResponse = result.current - const error = nextResponse.error as FetchBaseQueryError - expect(nextResponse.data).toBeUndefined() - expect(nextResponse.isLoading).toBe(false) - expect(nextResponse.isFetching).toBe(false) - expect(nextResponse.isSuccess).toBe(false) - expect(nextResponse.isError).toBe(true) - expect(error.status === 'FETCH_ERROR' && error.error).toBe('Error: Internal Server Error') - }) - }) - - it('using hook twice only fires a single request', async () => { - const storeRef = setupApiStore(transactionFeedV2Api, {}, reducersList) - const responseData: TransactionFeedV2Response = { - transactions: [{ transactionHash: '0x00' } as TokenTransaction], - pageInfo: { hasNextPage: false }, - } - fetchMock.mockResponse(JSON.stringify(responseData)) - - const address = '0x00' - const endCursor = 0 - - // First call fetches the data and stores it in cache - const { result } = renderHook( - () => storeRef.api.useTransactionFeedV2Query({ address, endCursor }), - { wrapper } - ) - - const initialResponse = result.current - expect(initialResponse.isLoading).toBe(true) - - await waitFor(() => { - const nextResponse = result.current - expect(nextResponse.data).not.toBeUndefined() - }) - - // Usage of hook with the same args returns data from cache and doesn't trigger another request - renderHook(() => storeRef.api.useTransactionFeedV2Query({ address, endCursor }), { - wrapper, - }) - renderHook(() => storeRef.api.useTransactionFeedV2Query({ address, endCursor }), { - wrapper, - }) - - expect(fetchMock).toBeCalledTimes(1) - }) - }) - }) -}) From eb72b45b50768f380279e03f4edc006791800a90 Mon Sep 17 00:00:00 2001 From: Myroslav Sviderok Date: Wed, 9 Oct 2024 10:54:06 +0300 Subject: [PATCH 10/16] add missing data from original mocked store to setupApiStore helper --- src/transactions/apiTestHelpers.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/transactions/apiTestHelpers.ts b/src/transactions/apiTestHelpers.ts index 3727efe1343..6940ed7bfab 100644 --- a/src/transactions/apiTestHelpers.ts +++ b/src/transactions/apiTestHelpers.ts @@ -2,7 +2,7 @@ import type { EnhancedStore, Middleware, Reducer, UnknownAction } from '@reduxjs import { combineReducers, configureStore } from '@reduxjs/toolkit' import { ApiReducersKeys } from 'src/redux/apiReducersList' import { RootState } from 'src/redux/reducers' -import { RecursivePartial } from 'test/utils' +import { getMockStoreData, RecursivePartial } from 'test/utils' /** * This function is taken from the Redux team. It creates a testable store that is compatible with RTK-Query. @@ -24,7 +24,7 @@ export function setupApiStore< >(api: A, preloadedState: Preloaded, extraReducers?: R) { const getStore = () => configureStore({ - preloadedState, + preloadedState: getMockStoreData(preloadedState), reducer: combineReducers({ [api.reducerPath]: api.reducer, ...extraReducers, From 652faf7ff49ded7e5afe5c1951202f0efa8c605e Mon Sep 17 00:00:00 2001 From: Myroslav Sviderok Date: Wed, 9 Oct 2024 10:59:20 +0300 Subject: [PATCH 11/16] knip fixes --- src/redux/api.ts | 2 +- src/transactions/api.ts | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/redux/api.ts b/src/redux/api.ts index fc95b6b0f8c..8ea344596e3 100644 --- a/src/redux/api.ts +++ b/src/redux/api.ts @@ -11,7 +11,7 @@ export const baseQuery = fetchBaseQuery({ }, }) -export function isRehydrateAction(action: Action): action is Action & { +function isRehydrateAction(action: Action): action is Action & { key: string payload: RootState err: unknown diff --git a/src/transactions/api.ts b/src/transactions/api.ts index 01e944d6234..5e9e8d8d334 100644 --- a/src/transactions/api.ts +++ b/src/transactions/api.ts @@ -2,7 +2,7 @@ import { createApi } from '@reduxjs/toolkit/query/react' import { baseQuery } from 'src/redux/api' import type { TokenTransaction } from 'src/transactions/types' -export type TransactionFeedV2Response = { +type TransactionFeedV2Response = { transactions: TokenTransaction[] pageInfo: { hasNextPage: boolean @@ -21,5 +21,3 @@ export const transactionFeedV2Api = createApi({ }), }), }) - -export const { useTransactionFeedV2Query } = transactionFeedV2Api From 28c343cdd004d075901b47d1bcd74c610b68cc8a Mon Sep 17 00:00:00 2001 From: Myroslav Sviderok Date: Wed, 9 Oct 2024 11:04:53 +0300 Subject: [PATCH 12/16] remove isRehydrateAcation --- src/redux/api.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/redux/api.ts b/src/redux/api.ts index 8ea344596e3..d1e185ebe44 100644 --- a/src/redux/api.ts +++ b/src/redux/api.ts @@ -1,7 +1,4 @@ import { fetchBaseQuery } from '@reduxjs/toolkit/query/react' -import { REHYDRATE } from 'redux-persist' -import type { Action } from 'redux-saga' -import type { RootState } from 'src/redux/reducers' import networkConfig from 'src/web3/networkConfig' export const baseQuery = fetchBaseQuery({ @@ -10,11 +7,3 @@ export const baseQuery = fetchBaseQuery({ Accept: 'application/json', }, }) - -function isRehydrateAction(action: Action): action is Action & { - key: string - payload: RootState - err: unknown -} { - return action.type === REHYDRATE -} From f2adff16f737dd8ea816ee53a3b8be62d902a01c Mon Sep 17 00:00:00 2001 From: Myroslav Sviderok Date: Wed, 9 Oct 2024 11:09:32 +0300 Subject: [PATCH 13/16] add apiTestHelpers toknip for now --- knip.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/knip.ts b/knip.ts index fb3ef278f92..18185582298 100644 --- a/knip.ts +++ b/knip.ts @@ -37,6 +37,7 @@ const config: KnipConfig = { 'src/utils/inputValidation.ts', 'src/utils/country.json', 'src/redux/reducersForSchemaGeneration.ts', + 'src/transactions/apiTestHelpers.ts', ], } From 5fd96e03d0d171723b7dd8fc5373c9f1b9b7c67b Mon Sep 17 00:00:00 2001 From: Myroslav Sviderok Date: Wed, 9 Oct 2024 12:19:05 +0300 Subject: [PATCH 14/16] add an explanation comment for reducersForSchemaGeneration.ts --- src/redux/reducersForSchemaGeneration.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/redux/reducersForSchemaGeneration.ts b/src/redux/reducersForSchemaGeneration.ts index 964366963db..86ead9a14e2 100644 --- a/src/redux/reducersForSchemaGeneration.ts +++ b/src/redux/reducersForSchemaGeneration.ts @@ -2,6 +2,18 @@ import { combineReducers } from 'redux' import { PersistState } from 'redux-persist' import { reducersList } from 'src/redux/reducersList' +/** + * All the reducers up to this point were always manually maintained/changed by us. + * For this purpose, we have Redux migrations which are covered with tests to ensure + * compatibility between different versions of Redux state. With introduction of RTK-Query + * we introduce library-managed api reducers, which must not be manually tweaked as library + * can change how it stores its structure (api reducer is the cache for API endpoint). + * + * For this purpose, it is mandatory to omit api reducers completely from the flow of + * typescript-json-schema generation. This file only includes existing non-api reducers + * necessary for schema generation. Trying to generate the schema with api reducers throws + * an error of unsupported type from @reduxjs/tookit/query/react. + */ const reducerForSchemaGeneration = combineReducers(reducersList) export type RootStateForSchemaGeneration = ReturnType & { _persist: PersistState From fb7f7dd85ac039818bdfe680d1b2503c775c6bb9 Mon Sep 17 00:00:00 2001 From: Myroslav Sviderok Date: Wed, 9 Oct 2024 12:27:15 +0300 Subject: [PATCH 15/16] refactor .reduce usage for readability --- src/redux/store.test.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/redux/store.test.ts b/src/redux/store.test.ts index 2edb05cb32c..716bc30634c 100644 --- a/src/redux/store.test.ts +++ b/src/redux/store.test.ts @@ -28,13 +28,19 @@ const resetStateOnInvalidStoredAccount = jest.spyOn( const loggerErrorSpy = jest.spyOn(Logger, 'error') function getNonApiReducers>(state: RootState): R { - const apiReducersKeys: string[] = Object.keys(apiReducersList) - return Object.entries(state).reduce((acc, [reducerKey, value]) => { - const key = reducerKey as keyof R - if (apiReducersKeys.includes(reducerKey)) return acc - acc[key] = value as unknown as any - return acc - }, {} as R) + const apiReducersKeys = Object.keys(apiReducersList) + const nonApiReducers = {} as R + + for (const [key, value] of Object.entries(state)) { + const isApiReducer = apiReducersKeys.includes(key) + + // api reducers are not persisted so skip them + if (isApiReducer) continue + + nonApiReducers[key as keyof R] = value as unknown as any + } + + return nonApiReducers } beforeEach(() => { From 2dad6088655b64a075c74cef38fdd29097749dc1 Mon Sep 17 00:00:00 2001 From: Myroslav Sviderok Date: Fri, 11 Oct 2024 10:41:57 +0300 Subject: [PATCH 16/16] feat(feed): Add initial implementation of Transaction Feed V2 (#6135) ### Description 2nd PR for RET-1207. Implements the following list of basic Transactions Feed functionality: - Adds fetching of all the pages for the wallet address up to the point when there are no transaction to fetch anymore (not showing "no transactions" toast yet, will be added in the follow-up PR). - Cursor for the next page is the timestamp of the last transaction from the current page. If next page includes the same transaction - it gets deduplicated. - Re-uses pending transactions from `pendingStandByTransactionsSelector` - Polls first page every 10 seconds - Sorts transactions to show approvals at the top if the corresponding transaction has identical timestamp More comments for different parts of the pagination flow are added as comments in the file in the [next PR](https://github.com/valora-inc/wallet/pull/6136). ### Test plan 10 out of 16 tests from `TransactionFeed.test.ts` were re-used and adjusted to the new data fetching flow. Other tests will be added in the follow-up PRs purely for the sake of trying to keep the PRs smaller. ### Related issues - Relates to RET-1207 ### Backwards compatibility Yes ### Network scalability If a new NetworkId and/or Network are added in the future, the changes in this PR will: - [x] Continue to work without code changes, OR trigger a compilation error (guaranteeing we find it when a new network is added) --- knip.ts | 1 - src/transactions/NoActivity.tsx | 6 +- src/transactions/api.ts | 10 +- src/transactions/apiTestHelpers.ts | 12 +- .../feed/TransactionFeedV2.test.tsx | 323 +++++++++++++++++ src/transactions/feed/TransactionFeedV2.tsx | 325 ++++++++++++++++++ src/transactions/reducer.ts | 3 +- src/transactions/types.ts | 2 + src/transactions/utils.ts | 11 +- 9 files changed, 678 insertions(+), 15 deletions(-) create mode 100644 src/transactions/feed/TransactionFeedV2.test.tsx create mode 100644 src/transactions/feed/TransactionFeedV2.tsx diff --git a/knip.ts b/knip.ts index 18185582298..fb3ef278f92 100644 --- a/knip.ts +++ b/knip.ts @@ -37,7 +37,6 @@ const config: KnipConfig = { 'src/utils/inputValidation.ts', 'src/utils/country.json', 'src/redux/reducersForSchemaGeneration.ts', - 'src/transactions/apiTestHelpers.ts', ], } diff --git a/src/transactions/NoActivity.tsx b/src/transactions/NoActivity.tsx index 40431e5a607..02cf3e84333 100644 --- a/src/transactions/NoActivity.tsx +++ b/src/transactions/NoActivity.tsx @@ -1,12 +1,14 @@ +import { type SerializedError } from '@reduxjs/toolkit' +import { type FetchBaseQueryError } from '@reduxjs/toolkit/query' import * as React from 'react' -import { WithTranslation } from 'react-i18next' +import { type WithTranslation } from 'react-i18next' import { ActivityIndicator, StyleSheet, Text, View } from 'react-native' import { withTranslation } from 'src/i18n' import colors from 'src/styles/colors' import { typeScale } from 'src/styles/fonts' interface OwnProps { loading: boolean - error: Error | undefined + error: Error | FetchBaseQueryError | SerializedError | undefined } type Props = OwnProps & WithTranslation diff --git a/src/transactions/api.ts b/src/transactions/api.ts index 5e9e8d8d334..2f78d2662c3 100644 --- a/src/transactions/api.ts +++ b/src/transactions/api.ts @@ -2,7 +2,7 @@ import { createApi } from '@reduxjs/toolkit/query/react' import { baseQuery } from 'src/redux/api' import type { TokenTransaction } from 'src/transactions/types' -type TransactionFeedV2Response = { +export type TransactionFeedV2Response = { transactions: TokenTransaction[] pageInfo: { hasNextPage: boolean @@ -17,7 +17,13 @@ export const transactionFeedV2Api = createApi({ TransactionFeedV2Response, { address: string; endCursor: number } >({ - query: ({ address, endCursor }) => `/wallet/${address}/transactions?endCursor=${endCursor}`, + query: ({ address, endCursor }) => { + const cursor = endCursor ? `?endCursor=${endCursor}` : '' + return `/wallet/${address}/transactions${cursor}` + }, + keepUnusedDataFor: 60, // 1 min }), }), }) + +export const { useTransactionFeedV2Query } = transactionFeedV2Api diff --git a/src/transactions/apiTestHelpers.ts b/src/transactions/apiTestHelpers.ts index 6940ed7bfab..73f426ed16d 100644 --- a/src/transactions/apiTestHelpers.ts +++ b/src/transactions/apiTestHelpers.ts @@ -5,8 +5,8 @@ import { RootState } from 'src/redux/reducers' import { getMockStoreData, RecursivePartial } from 'test/utils' /** - * This function is taken from the Redux team. It creates a testable store that is compatible with RTK-Query. - * It is slightly modified to also include the preloaded state. + * This function is taken from the Redux team. It creates a testable store that is compatible + * with RTK-Query. It is slightly modified to also include the preloaded state. * https://github.com/reduxjs/redux-toolkit/blob/e7540a5594b0d880037f2ff41a83a32c629d3117/packages/toolkit/src/tests/utils/helpers.tsx#L186 * * For more info on why this is needed and how it works - here's an article that answers some of the questions: @@ -17,7 +17,6 @@ export function setupApiStore< reducer: Reducer reducerPath: string middleware: Middleware - util: { resetApiState(): any } }, Preloaded extends RecursivePartial>, R extends Record> = Record, @@ -37,12 +36,9 @@ export function setupApiStore< }, }) + type Store = { api: ReturnType } & { [K in keyof R]: ReturnType } type StoreType = EnhancedStore< - { - api: ReturnType - } & { - [K in keyof R]: ReturnType - }, + Store, UnknownAction, ReturnType extends EnhancedStore ? M : never > diff --git a/src/transactions/feed/TransactionFeedV2.test.tsx b/src/transactions/feed/TransactionFeedV2.test.tsx new file mode 100644 index 00000000000..04e746a6558 --- /dev/null +++ b/src/transactions/feed/TransactionFeedV2.test.tsx @@ -0,0 +1,323 @@ +import { fireEvent, render, waitFor, within } from '@testing-library/react-native' +import { FetchMock } from 'jest-fetch-mock/types' +import React from 'react' +import { Provider } from 'react-redux' +import { ReactTestInstance } from 'react-test-renderer' +import { RootState } from 'src/redux/reducers' +import { reducersList } from 'src/redux/reducersList' +import { getDynamicConfigParams, getFeatureGate, getMultichainFeatures } from 'src/statsig' +import TransactionFeedV2 from 'src/transactions/feed/TransactionFeedV2' +import { + NetworkId, + TokenTransaction, + TokenTransactionTypeV2, + TransactionStatus, +} from 'src/transactions/types' +import { mockCusdAddress, mockCusdTokenId } from 'test/values' + +import { ApiReducersKeys } from 'src/redux/apiReducersList' +import { transactionFeedV2Api, type TransactionFeedV2Response } from 'src/transactions/api' +import { setupApiStore } from 'src/transactions/apiTestHelpers' +import { RecursivePartial } from 'test/utils' + +jest.mock('src/statsig') + +const STAND_BY_TRANSACTION_SUBTITLE_KEY = 'confirmingTransaction' +const mockFetch = fetch as FetchMock + +function mockTransaction(data?: Partial): TokenTransaction { + return { + __typename: 'TokenTransferV3', + networkId: NetworkId['celo-alfajores'], + address: '0xd68360cce1f1ff696d898f58f03e0f1252f2ea33', + amount: { + tokenId: mockCusdTokenId, + tokenAddress: mockCusdAddress, + value: '0.1', + }, + block: '8648978', + fees: [], + metadata: {}, + timestamp: 1542306118, + transactionHash: '0x544367eaf2b01622dd1c7b75a6b19bf278d72127aecfb2e5106424c40c268e8b2', + type: TokenTransactionTypeV2.Received, + status: TransactionStatus.Complete, + ...(data as any), + } +} + +function getNumTransactionItems(sectionList: ReactTestInstance) { + // data[0] is the first section in the section list - all mock transactions + // are for the same section / date + return sectionList.props.data[0].data.length +} + +const typedResponse = (response: Partial) => JSON.stringify(response) + +function renderScreen(storeOverrides: RecursivePartial> = {}) { + const state: typeof storeOverrides = { + web3: { account: '0x00' }, + ...storeOverrides, + } + const storeRef = setupApiStore(transactionFeedV2Api, state, reducersList) + + const tree = render( + + + + ) + + return { + ...tree, + store: storeRef.store, + } +} + +beforeEach(() => { + mockFetch.resetMocks() + jest.clearAllMocks() + jest.mocked(getMultichainFeatures).mockReturnValue({ + showCico: [NetworkId['celo-alfajores']], + showBalances: [NetworkId['celo-alfajores']], + showTransfers: [NetworkId['celo-alfajores']], + showApprovalTxsInHomefeed: [NetworkId['celo-alfajores']], + }) + jest.mocked(getDynamicConfigParams).mockReturnValue({ + jumpstartContracts: { + ['celo-alfajores']: { contractAddress: '0x7bf3fefe9881127553d23a8cd225a2c2442c438c' }, + }, + }) +}) + +describe('TransactionFeedV2', () => { + it('renders correctly when there is a response', async () => { + mockFetch.mockResponse(typedResponse({ transactions: [mockTransaction()] })) + const { store, ...tree } = renderScreen() + + await waitFor(() => expect(tree.getByTestId('TransactionList').props.data.length).toBe(1)) + expect(tree.queryByTestId('NoActivity/loading')).toBeNull() + expect(tree.queryByTestId('NoActivity/error')).toBeNull() + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(tree.getAllByTestId('TransferFeedItem').length).toBe(1) + expect( + within(tree.getByTestId('TransferFeedItem')).getByTestId('TransferFeedItem/title') + ).toHaveTextContent( + 'feedItemReceivedTitle, {"displayName":"feedItemAddress, {\\"address\\":\\"0xd683...ea33\\"}"}' + ) + }) + + it('renders correctly with completed standby transactions', async () => { + mockFetch.mockResponse(typedResponse({ transactions: [mockTransaction()] })) + + const tree = renderScreen({ + transactions: { + standbyTransactions: [mockTransaction({ transactionHash: '0x10' })], + }, + }) + + await waitFor(() => expect(tree.getAllByTestId('TransferFeedItem').length).toBe(2)) + }) + + it("doesn't render transfers for tokens that we don't know about", async () => { + mockFetch.mockResponse(typedResponse({ transactions: [mockTransaction()] })) + const { store, ...tree } = renderScreen() + + await waitFor(() => tree.getByTestId('TransactionList')) + const items = tree.getAllByTestId('TransferFeedItem/title') + expect(items.length).toBe(1) + }) + + it('renders the loading indicator while it loads', async () => { + const tree = renderScreen() + expect(tree.getByTestId('NoActivity/loading')).toBeDefined() + expect(tree.queryByTestId('NoActivity/error')).toBeNull() + expect(tree.queryByTestId('TransactionList')).toBeNull() + }) + + it("renders an error screen if there's no cache and the query fails", async () => { + mockFetch.mockReject(new Error('Test error')) + const tree = renderScreen() + + await waitFor(() => tree.getByTestId('NoActivity/error')) + expect(tree.queryByTestId('NoActivity/loading')).toBeNull() + expect(tree.queryByTestId('TransactionList')).toBeNull() + }) + + it('renders correctly when there are confirmed transactions and stand by transactions', async () => { + mockFetch.mockResponse(typedResponse({ transactions: [mockTransaction()] })) + + const tree = renderScreen({ + transactions: { + standbyTransactions: [ + mockTransaction({ transactionHash: '0x10', status: TransactionStatus.Pending }), + ], + }, + }) + + await waitFor(() => tree.getByTestId('TransactionList')) + + expect(tree.queryByTestId('NoActivity/loading')).toBeNull() + expect(tree.queryByTestId('NoActivity/error')).toBeNull() + + const subtitles = tree.queryAllByTestId('TransferFeedItem/subtitle') + + const pendingSubtitles = subtitles.filter((node) => + node.children.some((ch) => ch === STAND_BY_TRANSACTION_SUBTITLE_KEY) + ) + expect(pendingSubtitles.length).toBe(1) + }) + + it('renders correct status for a complete transaction', async () => { + mockFetch.mockResponse(typedResponse({ transactions: [mockTransaction()] })) + const tree = renderScreen() + + await waitFor(() => tree.getByTestId('TransactionList')) + expect(tree.getByText('feedItemReceivedInfo, {"context":"noComment"}')).toBeTruthy() + }) + + it('renders correct status for a failed transaction', async () => { + mockFetch.mockResponse( + typedResponse({ transactions: [mockTransaction({ status: TransactionStatus.Failed })] }) + ) + + const tree = renderScreen() + + await waitFor(() => tree.getByTestId('TransactionList')) + expect(tree.getByText('feedItemFailedTransaction')).toBeTruthy() + }) + + it('tries to fetch pages until the end is reached', async () => { + mockFetch + .mockResponseOnce( + typedResponse({ + transactions: [mockTransaction({ transactionHash: '0x01', timestamp: 10 })], + }) + ) + .mockResponseOnce( + typedResponse({ + transactions: [mockTransaction({ transactionHash: '0x02', timestamp: 20 })], + }) + ) + .mockResponseOnce(typedResponse({ transactions: [] })) + + const { store, ...tree } = renderScreen() + + await waitFor(() => tree.getByTestId('TransactionList')) + + fireEvent(tree.getByTestId('TransactionList'), 'onEndReached') + await waitFor(() => expect(mockFetch).toBeCalled()) + await waitFor(() => expect(tree.getByTestId('TransactionList/loading')).toBeVisible()) + await waitFor(() => expect(tree.queryByTestId('TransactionList/loading')).toBeFalsy()) + + fireEvent(tree.getByTestId('TransactionList'), 'onEndReached') + await waitFor(() => expect(mockFetch).toBeCalled()) + await waitFor(() => expect(tree.getByTestId('TransactionList/loading')).toBeVisible()) + await waitFor(() => expect(tree.queryByTestId('TransactionList/loading')).toBeFalsy()) + + expect(mockFetch).toHaveBeenCalledTimes(3) + expect(getNumTransactionItems(tree.getByTestId('TransactionList'))).toBe(2) + }) + + it('tries to fetch a page of transactions, and stores empty pages', async () => { + mockFetch + .mockResponseOnce(typedResponse({ transactions: [mockTransaction()] })) + .mockResponseOnce(typedResponse({ transactions: [] })) + + const { store, ...tree } = renderScreen() + + await store.dispatch( + transactionFeedV2Api.endpoints.transactionFeedV2.initiate({ address: '0x00', endCursor: 0 }) + ) + await store.dispatch( + transactionFeedV2Api.endpoints.transactionFeedV2.initiate({ address: '0x00', endCursor: 123 }) + ) + + await waitFor(() => tree.getByTestId('TransactionList')) + + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2)) + expect(getNumTransactionItems(tree.getByTestId('TransactionList'))).toBe(1) + }) + + it('renders GetStarted if SHOW_GET_STARTED is enabled and transaction feed is empty', async () => { + jest.mocked(getFeatureGate).mockReturnValue(true) + const tree = renderScreen() + expect(tree.getByTestId('GetStarted')).toBeDefined() + }) + + it('renders NoActivity by default if transaction feed is empty', async () => { + jest.mocked(getFeatureGate).mockReturnValue(false) + const tree = renderScreen() + expect(tree.getByTestId('NoActivity/loading')).toBeDefined() + expect(tree.getByText('noTransactionActivity')).toBeTruthy() + }) + + it('useStandByTransactions properly splits pending/confirmed transactions', async () => { + mockFetch.mockResponse( + typedResponse({ + transactions: [ + mockTransaction({ transactionHash: '0x4000000' }), // confirmed + mockTransaction({ transactionHash: '0x3000000' }), // confirmed + mockTransaction({ transactionHash: '0x2000000' }), // confirmed + mockTransaction({ transactionHash: '0x1000000' }), // confirmed + ], + }) + ) + + const { store, ...tree } = renderScreen({ + transactions: { + standbyTransactions: [ + mockTransaction({ transactionHash: '0x10', status: TransactionStatus.Complete }), // confirmed + mockTransaction({ transactionHash: '0x20', status: TransactionStatus.Complete }), // confirmed + mockTransaction({ transactionHash: '0x30', status: TransactionStatus.Pending }), // pending + mockTransaction({ transactionHash: '0x40', status: TransactionStatus.Pending }), // pending + mockTransaction({ transactionHash: '0x50', status: TransactionStatus.Failed }), // confirmed + ], + }, + }) + + await store.dispatch( + transactionFeedV2Api.endpoints.transactionFeedV2.initiate({ address: '0x00', endCursor: 0 }) + ) + + await waitFor(() => { + expect(tree.getByTestId('TransactionList').props.data.length).toBe(2) + }) + + // from total of 9 transactions there should be 2 pending in a "recent" section + expect(tree.getByTestId('TransactionList').props.data[0].data.length).toBe(2) + // from total of 9 transactions there should be 7 confirmed in a "general" section + expect(tree.getByTestId('TransactionList').props.data[1].data.length).toBe(7) + }) + + it('merges only those stand by transactions that fit the timeline between min/max timestamps of the page', async () => { + mockFetch.mockResponse( + typedResponse({ + transactions: [ + mockTransaction({ transactionHash: '0x4000000', timestamp: 49 }), // max + mockTransaction({ transactionHash: '0x3000000', timestamp: 47 }), + mockTransaction({ transactionHash: '0x2000000', timestamp: 25 }), + mockTransaction({ transactionHash: '0x1000000', timestamp: 21 }), // min + ], + }) + ) + + const { store, ...tree } = renderScreen({ + transactions: { + standbyTransactions: [ + mockTransaction({ transactionHash: '0x10', timestamp: 10 }), // not in scope + mockTransaction({ transactionHash: '0x20', timestamp: 20 }), // not in scope + mockTransaction({ transactionHash: '0x30', timestamp: 30 }), // in scope + mockTransaction({ transactionHash: '0x40', timestamp: 40 }), // in scope + mockTransaction({ transactionHash: '0x50', timestamp: 50 }), // not in scope + ], + }, + }) + + await store.dispatch( + transactionFeedV2Api.endpoints.transactionFeedV2.initiate({ address: '0x00', endCursor: 0 }) + ) + + await waitFor(() => tree.getByTestId('TransactionList')) + expect(getNumTransactionItems(tree.getByTestId('TransactionList'))).toBe(6) + }) +}) diff --git a/src/transactions/feed/TransactionFeedV2.tsx b/src/transactions/feed/TransactionFeedV2.tsx new file mode 100644 index 00000000000..23f536be1dd --- /dev/null +++ b/src/transactions/feed/TransactionFeedV2.tsx @@ -0,0 +1,325 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { ActivityIndicator, SectionList, StyleSheet, View } from 'react-native' +import SectionHead from 'src/components/SectionHead' +import GetStarted from 'src/home/GetStarted' +import { useSelector } from 'src/redux/hooks' +import { getFeatureGate, getMultichainFeatures } from 'src/statsig' +import { StatsigFeatureGates } from 'src/statsig/types' +import colors from 'src/styles/colors' +import { Spacing } from 'src/styles/styles' +import NoActivity from 'src/transactions/NoActivity' +import { useTransactionFeedV2Query } from 'src/transactions/api' +import EarnFeedItem from 'src/transactions/feed/EarnFeedItem' +import NftFeedItem from 'src/transactions/feed/NftFeedItem' +import SwapFeedItem from 'src/transactions/feed/SwapFeedItem' +import TokenApprovalFeedItem from 'src/transactions/feed/TokenApprovalFeedItem' +import TransferFeedItem from 'src/transactions/feed/TransferFeedItem' +import { allStandbyTransactionsSelector } from 'src/transactions/reducer' +import { + TokenTransactionTypeV2, + TransactionStatus, + type NetworkId, + type NftTransfer, + type TokenApproval, + type TokenEarn, + type TokenExchange, + type TokenTransaction, + type TokenTransfer, +} from 'src/transactions/types' +import { + groupFeedItemsInSections, + standByTransactionToTokenTransaction, +} from 'src/transactions/utils' +import { walletAddressSelector } from 'src/web3/selectors' + +type PaginatedData = { + [timestamp: number]: TokenTransaction[] +} + +// Query poll interval +const POLL_INTERVAL_MS = 10000 // 10 sec +const FIRST_PAGE_TIMESTAMP = 0 + +function getAllowedNetworksForTransfers() { + return getMultichainFeatures().showTransfers +} + +/** + * Join allowed networks into a string to help react memoization. + * N.B: This fetch-time filtering does not suffice to prevent non-Celo TXs from appearing + * on the home feed, since they get cached in Redux -- this is just a network optimization. + */ +function useAllowedNetworksForTransfers() { + const allowedNetworks = getAllowedNetworksForTransfers().join(',') + return useMemo(() => allowedNetworks.split(',') as NetworkId[], [allowedNetworks]) +} + +/** + * This function uses the same deduplication approach as "deduplicateTransactions" function from + * queryHelper but only for a single flattened array instead of two. + * Also, the queryHelper is going to be removed once we fully migrate to TransactionFeedV2, + * so this function would have needed to be moved from queryHelper anyway. + */ +function deduplicateTransactions(transactions: TokenTransaction[]): TokenTransaction[] { + const transactionMap: { [txHash: string]: TokenTransaction } = {} + + for (const tx of transactions) { + transactionMap[tx.transactionHash] = tx + } + + return Object.values(transactionMap) +} + +/** + * If the timestamps are the same, most likely one of the transactions is an approval. + * On the feed we want to show the approval first. + */ +function sortTransactions(transactions: TokenTransaction[]): TokenTransaction[] { + return transactions.sort((a, b) => { + const diff = b.timestamp - a.timestamp + + if (diff === 0) { + if (a.type === TokenTransactionTypeV2.Approval) return 1 + if (b.type === TokenTransactionTypeV2.Approval) return -1 + return 0 + } + + return diff + }) +} + +/** + * Every page of paginated data includes a limited amount of transactions within a certain period. + * In standByTransactions we might have transactions from months ago. Whenever we load a new page + * we only want to add those stand by transactions that are within the time period of the new page. + * Otherwise, if we merge all the stand by transactins into the page it will cause more late transactions + * that were already merged to be removed from the top of the list and move them to the bottom. + * This will cause the screen to "shift", which we're trying to avoid. + */ +function mergeStandByTransactionsInRange( + transactions: TokenTransaction[], + standBy: TokenTransaction[] +): TokenTransaction[] { + if (transactions.length === 0) return [] + + const allowedNetworks = getAllowedNetworksForTransfers() + const max = transactions[0].timestamp + const min = transactions.at(-1)!.timestamp + + const standByInRange = standBy.filter((tx) => tx.timestamp >= min && tx.timestamp <= max) + const deduplicatedTransactions = deduplicateTransactions([...transactions, ...standByInRange]) + const transactionsFromAllowedNetworks = deduplicatedTransactions.filter((tx) => + allowedNetworks.includes(tx.networkId) + ) + + return transactionsFromAllowedNetworks +} + +/** + * Current implementation of allStandbyTransactionsSelector contains function + * getSupportedNetworkIdsForApprovalTxsInHomefeed in its selectors list which triggers a lot of + * unnecessary re-renders. This can be avoided if we join it's result in a string and memoize it, + * similar to how it was done with useAllowedNetworkIdsForTransfers hook from queryHelpers.ts + * + * Not using existing selectors for pending/confirmed stand by transaction only cause they are + * dependant on the un-memoized standbyTransactionsSelector selector which will break the new + * pagination flow. + * + * Implementation of pending is identical to pendingStandbyTransactionsSelector. + * Implementation of confirmed is identical to confirmedStandbyTransactionsSelector. + */ +function useStandByTransactions() { + const standByTransactions = useSelector(allStandbyTransactionsSelector) + const allowedNetworkForTransfers = useAllowedNetworksForTransfers() + + return useMemo(() => { + const transactionsFromAllowedNetworks = standByTransactions.filter((tx) => + allowedNetworkForTransfers.includes(tx.networkId) + ) + + const pending: TokenTransaction[] = [] + const confirmed: TokenTransaction[] = [] + + for (const tx of transactionsFromAllowedNetworks) { + if (tx.status === TransactionStatus.Pending) { + pending.push(standByTransactionToTokenTransaction(tx)) + } else { + confirmed.push(tx) + } + } + + return { pending, confirmed } + }, [standByTransactions, allowedNetworkForTransfers]) +} + +function renderItem({ item: tx }: { item: TokenTransaction }) { + switch (tx.type) { + case TokenTransactionTypeV2.Exchange: + case TokenTransactionTypeV2.SwapTransaction: + case TokenTransactionTypeV2.CrossChainSwapTransaction: + return + case TokenTransactionTypeV2.Sent: + case TokenTransactionTypeV2.Received: + return + case TokenTransactionTypeV2.NftSent: + case TokenTransactionTypeV2.NftReceived: + return + case TokenTransactionTypeV2.Approval: + return + case TokenTransactionTypeV2.EarnDeposit: + case TokenTransactionTypeV2.EarnSwapDeposit: + case TokenTransactionTypeV2.EarnWithdraw: + case TokenTransactionTypeV2.EarnClaimReward: + return + } +} + +export default function TransactionFeedV2() { + const address = useSelector(walletAddressSelector) + const standByTransactions = useStandByTransactions() + const [endCursor, setEndCursor] = useState(FIRST_PAGE_TIMESTAMP) + const [paginatedData, setPaginatedData] = useState({ [FIRST_PAGE_TIMESTAMP]: [] }) + + /** + * This hook automatically fetches the pagination data when (and only when) the endCursor changes + * (we can safely ignore wallet address change as it's impossible to get changed on the fly). + * When components mounts, it fetches data for the first page using FIRST_PAGE_TIMESTAMP for endCursor + * (which is ignored in the request only for the first page as it's just an endCursor placeholder). + * Once the data is returned – we process it with "selectFromResult" for convenience and return the + * data. It gets further processed within the "updatePaginatedData" useEffect. + * + * Cursor for the next page is the timestamp of the last transaction of the last fetched page. + * This hook doesn't refetch data for none of the pages, neither does it do any polling. It's + * intention is to only fetch the next page whenever endCursor changes. Polling is handled by + * calling the same hook below. + */ + const { data, originalArgs, nextCursor, isFetching, error } = useTransactionFeedV2Query( + { address: address!, endCursor }, + { + skip: !address, + refetchOnMountOrArgChange: true, + selectFromResult: (result) => ({ + ...result, + nextCursor: result.data?.transactions.at(-1)?.timestamp, + }), + } + ) + + /** + * This is the same hook as above and it only triggers the fetch request. It's intention is to + * only poll the data for the first page of the feed, using the FIRST_PAGE_TIMESTAMP endCursor. + * Thanks to how RTK-Query stores the fetched data, we know that using "useTransactionFeedV2Query" + * with the same arguments in multiple places will always point to the same data. This means, that + * we can trigger fetch request here and once data arrives - the same hook above will re-run the + * "selectFromResult" function for FIRST_PAGE_TIMESTAMP endCursor and will trigger the data update + * flow for the first page. + */ + useTransactionFeedV2Query( + { address: address!, endCursor: FIRST_PAGE_TIMESTAMP }, + { skip: !address, pollingInterval: POLL_INTERVAL_MS } + ) + + useEffect( + function updatePaginatedData() { + if (isFetching) return + + const currentCursor = originalArgs?.endCursor // timestamp from the last transaction from the previous page. + const transactions = data?.transactions || [] + + /** + * There are only 2 scenarios when we actually update the paginated data: + * + * 1. Always update the first page. First page will be polled every "POLL_INTERVAL" + * milliseconds. Whenever new data arrives - replace the existing first page data + * with the new data as it might contain some updated information about the transactions + * that are already present. The first page should not contain an empty array, unless + * wallet doesn't have any transactions at all. + * + * 2. Data for every page after the first page is only set once. All the pending transactions + * are supposed to arrive in the first page so everything after the first page can be + * considered confirmed (completed/failed). For this reason, there's no point in updating + * the data as its very unlikely to update. + */ + setPaginatedData((prev) => { + const isFirstPage = currentCursor === FIRST_PAGE_TIMESTAMP + const pageDataIsAbsent = + currentCursor !== FIRST_PAGE_TIMESTAMP && // not the first page + currentCursor !== undefined && // it is a page after the first + prev[currentCursor] === undefined // data for this page wasn't stored yet + + if (isFirstPage || pageDataIsAbsent) { + const mergedTransactions = mergeStandByTransactionsInRange( + transactions, + standByTransactions.confirmed + ) + + return { ...prev, [currentCursor!]: mergedTransactions } + } + + return prev + }) + }, + [isFetching, data?.transactions, originalArgs?.endCursor, standByTransactions.confirmed] + ) + + const confirmedTransactions = useMemo(() => { + const flattenedPages = Object.values(paginatedData).flat() + const deduplicatedTransactions = deduplicateTransactions(flattenedPages) + const sortedTransactions = sortTransactions(deduplicatedTransactions) + return sortedTransactions + }, [paginatedData]) + + const sections = useMemo(() => { + const noPendingTransactions = standByTransactions.pending.length === 0 + const noConfirmedTransactions = confirmedTransactions.length === 0 + if (noPendingTransactions && noConfirmedTransactions) return [] + return groupFeedItemsInSections(standByTransactions.pending, confirmedTransactions) + }, [standByTransactions.pending, confirmedTransactions]) + + if (!sections.length) { + return getFeatureGate(StatsigFeatureGates.SHOW_GET_STARTED) ? ( + + ) : ( + + ) + } + + function fetchMoreTransactions() { + if (nextCursor) { + setEndCursor(nextCursor) + } + } + + return ( + <> + } + sections={sections} + keyExtractor={(item) => `${item.transactionHash}-${item.timestamp.toString()}`} + keyboardShouldPersistTaps="always" + testID="TransactionList" + onEndReached={fetchMoreTransactions} + initialNumToRender={20} + /> + {isFetching && ( + + + + )} + + ) +} + +const styles = StyleSheet.create({ + loadingIcon: { + marginVertical: Spacing.Thick24, + height: 108, + width: 108, + }, + centerContainer: { + alignItems: 'center', + justifyContent: 'center', + flex: 1, + }, +}) diff --git a/src/transactions/reducer.ts b/src/transactions/reducer.ts index b12ee4f6529..d09eb81e42e 100644 --- a/src/transactions/reducer.ts +++ b/src/transactions/reducer.ts @@ -167,7 +167,8 @@ export const reducer = ( } } -const allStandbyTransactionsSelector = (state: RootState) => state.transactions.standbyTransactions +export const allStandbyTransactionsSelector = (state: RootState) => + state.transactions.standbyTransactions const standbyTransactionsSelector = createSelector( [allStandbyTransactionsSelector, getSupportedNetworkIdsForApprovalTxsInHomefeed], (standbyTransactions, supportedNetworkIdsForApprovalTxs) => { diff --git a/src/transactions/types.ts b/src/transactions/types.ts index e75ea8debb9..f25f8a58567 100644 --- a/src/transactions/types.ts +++ b/src/transactions/types.ts @@ -285,3 +285,5 @@ export interface TrackedTx { txHash: Hash | undefined txReceipt: TransactionReceipt | undefined } + +export type TokenEarn = EarnWithdraw | EarnDeposit | EarnClaimReward | EarnSwapDeposit diff --git a/src/transactions/utils.ts b/src/transactions/utils.ts index 221d2941fd1..9506759348f 100644 --- a/src/transactions/utils.ts +++ b/src/transactions/utils.ts @@ -2,7 +2,7 @@ import BigNumber from 'bignumber.js' import { PrefixedTxReceiptProperties, TxReceiptProperties } from 'src/analytics/Properties' import i18n from 'src/i18n' import { TokenBalances } from 'src/tokens/slice' -import { NetworkId, TrackedTx } from 'src/transactions/types' +import { NetworkId, StandbyTransaction, TokenTransaction, TrackedTx } from 'src/transactions/types' import { formatFeedSectionTitle, timeDeltaInDays } from 'src/utils/time' import { getEstimatedGasFee, @@ -120,3 +120,12 @@ export function getPrefixedTxAnalyticsProperties( } return prefixedProperties as Partial> } + +export function standByTransactionToTokenTransaction(tx: StandbyTransaction): TokenTransaction { + return { + fees: [], + block: '', + transactionHash: '', + ...tx, // in case the transaction already has the above (e.g. cross chain swaps), use the real values + } +}