Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 12 additions & 37 deletions packages/suite/src/reducers/createReduxStore.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { type Store, type UnknownAction, configureStore } from '@reduxjs/toolkit';
import { type ThunkDispatch, type ThunkMiddleware } from 'redux-thunk';
import { configureStore } from '@reduxjs/toolkit';

import { MODAL_OPEN_USER_CONTEXT } from '@suite/modal';
import { type ExtraDependenciesStatic } from '@suite-common/extra-dependencies';
import { type ReduxStoreWithThunk, createReduxExtra } from '@suite-common/redux-utils';
import { type TokenDefinitionsMiddlewareDeps } from '@suite-common/token-definitions';

import { type SuiteServices } from 'src/support/createSuiteCompositionRoot';
import { type ExtraDependenciesSuite } from 'src/support/extraDependencies';
Expand All @@ -10,12 +12,10 @@ import { type AppState, type SuiteRootReducer, devTools, getCustomMiddleware } f

type ReduxStoreDeps = {
reducer: SuiteRootReducer;
extraDependencies: Omit<ExtraDependenciesSuite, 'services'>;
extraDependencies: ExtraDependenciesStatic & TokenDefinitionsMiddlewareDeps;
};

export type SuiteReduxStore = Store<AppState> & {
dispatch: ThunkDispatch<AppState, ExtraDependenciesSuite, UnknownAction>;
};
export type SuiteReduxStore = ReduxStoreWithThunk<AppState, ExtraDependenciesSuite>;

export type SuiteReduxStoreDep = { store: SuiteReduxStore };

Expand All @@ -27,31 +27,11 @@ export type ReduxStore = {
export type ReduxStoreDep = { reduxStore: ReduxStore };

export const createReduxStore = (deps: ReduxStoreDeps): ReduxStore => {
let extra: ExtraDependenciesSuite | null = null;

const getExtra = (): ExtraDependenciesSuite => {
if (extra === null) {
throw new Error(
'Redux services must be injected before dispatching application actions.',
);
}

return extra;
};

// Resolve extra at dispatch time: services need the real store to be constructed first.
const thunkMiddleware: ThunkMiddleware<AppState, UnknownAction, ExtraDependenciesSuite> =
({ dispatch, getState }) =>
next =>
action => {
const currentExtra = getExtra();

if (typeof action === 'function') {
return action(dispatch, getState, currentExtra);
}

return next(action);
};
const { getExtra, thunkMiddleware, injectServicesIntoReduxExtra } = createReduxExtra<
AppState,
SuiteServices,
ExtraDependenciesStatic & TokenDefinitionsMiddlewareDeps
>({ extraDependencies: deps.extraDependencies });

const store = configureStore({
reducer: deps.reducer,
Expand All @@ -75,11 +55,6 @@ export const createReduxStore = (deps: ReduxStoreDeps): ReduxStore => {

return {
store,
injectServicesIntoReduxExtra: services => {
// Services depend on this store's dispatch/getState, while thunks depend on services.
// The parent composition root creates the store first, then builds and injects the
// services here to break that cycle before any application actions are dispatched.
extra = { ...deps.extraDependencies, services };
},
injectServicesIntoReduxExtra,
};
};
52 changes: 52 additions & 0 deletions suite-common/redux-utils/src/createReduxExtra.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { configureStore } from '@reduxjs/toolkit';

import { createReduxExtra } from './createReduxExtra';

const createTestDeps = () => {
const reduxExtra = createReduxExtra<number, { getValue: () => number }, { prefix: string }>({
extraDependencies: { prefix: 'value' },
});
const store = configureStore({
reducer: (state = 1) => state,
middleware: getDefaultMiddleware =>
getDefaultMiddleware({ thunk: false }).prepend(reduxExtra.thunkMiddleware),
});

return { store, ...reduxExtra };
};

describe(createReduxExtra.name, () => {
it('rejects application actions before services are injected', () => {
const { store } = createTestDeps();

expect(() => store.dispatch({ type: 'test' })).toThrow(
'Redux services must be injected before dispatching application actions.',
);
});

it('provides state, dispatch and injected dependencies to thunks', () => {
const { store, getExtra, injectServicesIntoReduxExtra } = createTestDeps();
const services = { getValue: () => 2 };
injectServicesIntoReduxExtra(services);

const result = store.dispatch((dispatch, getState, extra) => {
dispatch({ type: 'test' });

return `${extra.prefix}: ${getState() + extra.services.getValue()}`;
});

expect(result).toBe('value: 3');
expect(getExtra()).toEqual({ prefix: 'value', services });
});

it('keeps injected services isolated between store instances', () => {
const first = createTestDeps();
const second = createTestDeps();
first.injectServicesIntoReduxExtra({ getValue: () => 2 });

expect(() => second.getExtra()).toThrow();
second.injectServicesIntoReduxExtra({ getValue: () => 3 });
expect(first.getExtra().services.getValue()).toBe(2);
expect(second.getExtra().services.getValue()).toBe(3);
});
});
67 changes: 67 additions & 0 deletions suite-common/redux-utils/src/createReduxExtra.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import {
type Store,
type ThunkDispatch,
type ThunkMiddleware,
type UnknownAction,
} from '@reduxjs/toolkit';

type ReduxExtraDeps<TStaticExtra> = { extraDependencies: TStaticExtra };

type ExtraWithServices<TServices, TStaticExtra> = TStaticExtra & { services: TServices };

export type ReduxStoreWithThunk<TState, TExtra> = Store<TState> & {
dispatch: ThunkDispatch<TState, TExtra, UnknownAction>;
};

type ReduxExtra<TState, TServices, TStaticExtra> = {
getExtra: () => ExtraWithServices<TServices, TStaticExtra>;
thunkMiddleware: ThunkMiddleware<
TState,
UnknownAction,
ExtraWithServices<TServices, TStaticExtra>
>;
injectServicesIntoReduxExtra: (services: TServices) => void;
};

export const createReduxExtra = <TState, TServices, TStaticExtra>(
deps: ReduxExtraDeps<TStaticExtra>,
): ReduxExtra<TState, TServices, TStaticExtra> => {
let extra: ExtraWithServices<TServices, TStaticExtra> | null = null;

const getExtra = (): ExtraWithServices<TServices, TStaticExtra> => {
if (extra === null) {
throw new Error(
'Redux services must be injected before dispatching application actions.',
);
}

return extra;
};

// Resolve extra at dispatch time: services need the real store to be constructed first.
const thunkMiddleware: ThunkMiddleware<
TState,
UnknownAction,
ExtraWithServices<TServices, TStaticExtra>
> =
({ dispatch, getState }) =>
next =>
action => {
const currentExtra = getExtra();

if (typeof action === 'function') {
return action(dispatch, getState, currentExtra);
}

return next(action);
};

return {
getExtra,
thunkMiddleware,
// The composition root completes this cycle before application actions are dispatched.
injectServicesIntoReduxExtra: services => {
extra = { ...deps.extraDependencies, services };
},
};
};
1 change: 1 addition & 0 deletions suite-common/redux-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export * from './hooks/useSelectorDeepComparison';
export * from './hooks/useDispatch';
export * from './selectorsUtils';
export * from './extraWithStoreThunkMiddleware';
export { createReduxExtra, type ReduxStoreWithThunk } from './createReduxExtra';
10 changes: 2 additions & 8 deletions suite-native/app/e2e/tests/tradingExchangeFlow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,12 @@ describe('Trade Exchange [@androidOnly]', () => {

// Skipping due to emulator crash
describe('with device disconnected [@T3T1]', () => {
beforeAll(() => {
beforeEach(async () => {
if (!passphrase) {
throw new Error(
'TRADING_ACADEMIC_SEED_WALLET_PASSPHRASE environment variable is required',
);
}
});

beforeEach(async () => {
await prepareTrezorEmulator({
seed: MNEMONICS.mnemonic_academic,
passphrase_protection: true,
Expand Down Expand Up @@ -80,15 +77,12 @@ describe('Trade Exchange [@androidOnly]', () => {

// Skipping due to emulator crash
describe('with device connected [@T3T1]', () => {
beforeAll(() => {
beforeEach(async () => {
if (!passphrase) {
throw new Error(
'TRADING_ACADEMIC_SEED_WALLET_PASSPHRASE environment variable is required',
);
}
});

beforeEach(async () => {
await prepareTrezorEmulator({
seed: MNEMONICS.mnemonic_academic,
passphrase_protection: true,
Expand Down
10 changes: 2 additions & 8 deletions suite-native/app/e2e/tests/tradingSellFlow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,12 @@ describe('Trade Sell [@androidOnly]', () => {
});

describe('with device disconnected [@T3T1]', () => {
beforeAll(() => {
beforeEach(async () => {
if (!passphrase) {
throw new Error(
'TRADING_ACADEMIC_SEED_WALLET_PASSPHRASE environment variable is required',
);
}
});

beforeEach(async () => {
await prepareTrezorEmulator({
seed: MNEMONICS.mnemonic_academic,
passphrase_protection: true,
Expand Down Expand Up @@ -80,15 +77,12 @@ describe('Trade Sell [@androidOnly]', () => {
});

describe('with device connected [@T3T1]', () => {
beforeAll(() => {
beforeEach(async () => {
if (!passphrase) {
throw new Error(
'TRADING_ACADEMIC_SEED_WALLET_PASSPHRASE environment variable is required',
);
}
});

beforeEach(async () => {
await prepareTrezorEmulator({
seed: MNEMONICS.mnemonic_academic,
passphrase_protection: true,
Expand Down
10 changes: 9 additions & 1 deletion suite-native/app/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,18 @@ import 'react-native-gesture-handler';
import './rozeniteBootRecording';
import './globalPolyfills';
import './reanimatedLoggerFix';
import './src/initSentry';

import { registerRootComponent } from 'expo';

import { App } from './src/App';
import { markStartupJsBundleEvaluated } from '@suite-native/sentry';

import { createSuiteNativeCompositionRoot } from './src/createSuiteNativeCompositionRoot';

markStartupJsBundleEvaluated();

const { init } = createSuiteNativeCompositionRoot();
const App = init();

// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
Expand Down
3 changes: 1 addition & 2 deletions suite-native/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@
"@suite-common/firmware-authenticity": "workspace:*",
"@suite-common/formatters": "workspace:*",
"@suite-common/react-query": "workspace:^",
"@suite-common/redux-utils": "workspace:*",
"@suite-common/suite-constants": "workspace:*",
"@suite-common/suite-sync": "workspace:*",
"@suite-common/suite-types": "workspace:*",
Expand Down Expand Up @@ -105,6 +104,7 @@
"@suite-native/services": "workspace:*",
"@suite-native/settings": "workspace:*",
"@suite-native/state": "workspace:*",
"@suite-native/storage": "workspace:*",
"@suite-native/theme": "workspace:*",
"@suite-native/toasts": "workspace:*",
"@suite-native/trading-residence": "workspace:*",
Expand Down Expand Up @@ -168,7 +168,6 @@
"react-native-tcp-socket": "6.4.1",
"react-native-worklets": "0.10.0",
"react-redux": "9.3.0",
"redux-persist": "6.0.0",
"set.prototype.difference": "^1.1.7",
"set.prototype.intersection": "^1.1.7",
"set.prototype.isdisjointfrom": "^1.1.5",
Expand Down
Loading
Loading