diff --git a/src/BaseControllerV2.ts b/src/BaseControllerV2.ts index 7f532ac80b..df00b3c89b 100644 --- a/src/BaseControllerV2.ts +++ b/src/BaseControllerV2.ts @@ -129,7 +129,12 @@ export class BaseController< string | never >; - private name: N; + /** + * The name of the controller. + * + * This is used by the ComposableController to construct a composed application state. + */ + public readonly name: N; public readonly metadata: StateMetadata; diff --git a/src/ComposableController.test.ts b/src/ComposableController.test.ts index d23144de27..435bc41ea1 100644 --- a/src/ComposableController.test.ts +++ b/src/ComposableController.test.ts @@ -1,7 +1,14 @@ import { stub } from 'sinon'; +import type { Patch } from 'immer'; import AddressBookController from './user/AddressBookController'; import EnsController from './third-party/EnsController'; import ComposableController from './ComposableController'; +import { BaseController, BaseState } from './BaseController'; +import { BaseController as BaseControllerV2 } from './BaseControllerV2'; +import { + ControllerMessenger, + RestrictedControllerMessenger, +} from './ControllerMessenger'; import PreferencesController from './user/PreferencesController'; import TokenRatesController from './assets/TokenRatesController'; import { AssetsController } from './assets/AssetsController'; @@ -12,178 +19,220 @@ import { import { AssetsContractController } from './assets/AssetsContractController'; import CurrencyRateController from './assets/CurrencyRateController'; +// Mock BaseControllerV2 classes + +type FooControllerState = { + foo: string; +}; +type FooControllerEvent = { + type: `FooController:stateChange`; + payload: [FooControllerState, Patch[]]; +}; + +const fooControllerStateMetadata = { + foo: { + persist: true, + anonymous: true, + }, +}; + +class FooController extends BaseControllerV2< + 'FooController', + FooControllerState +> { + constructor( + messagingSystem: RestrictedControllerMessenger< + 'FooController', + never, + FooControllerEvent, + string, + never + >, + ) { + super({ + messenger: messagingSystem, + metadata: fooControllerStateMetadata, + name: 'FooController', + state: { foo: 'foo' }, + }); + } + + updateFoo(foo: string) { + super.update((state) => { + state.foo = foo; + }); + } +} + +// Mock BaseController classes + +interface BarControllerState extends BaseState { + bar: string; +} +class BarController extends BaseController { + defaultState = { + bar: 'bar', + }; + + name = 'BarController'; + + constructor() { + super(); + this.initialize(); + } + + updateBar(bar: string) { + super.update({ bar }); + } +} + describe('ComposableController', () => { - it('should compose controller state', () => { - const preferencesController = new PreferencesController(); - const networkController = new NetworkController(); - const assetContractController = new AssetsContractController(); - const assetController = new AssetsController({ - onPreferencesStateChange: (listener) => - preferencesController.subscribe(listener), - onNetworkStateChange: (listener) => networkController.subscribe(listener), - getAssetName: assetContractController.getAssetName.bind( - assetContractController, - ), - getAssetSymbol: assetContractController.getAssetSymbol.bind( + describe('BaseController', () => { + it('should compose controller state', () => { + const preferencesController = new PreferencesController(); + const networkController = new NetworkController(); + const assetContractController = new AssetsContractController(); + const assetController = new AssetsController({ + onPreferencesStateChange: (listener) => + preferencesController.subscribe(listener), + onNetworkStateChange: (listener) => + networkController.subscribe(listener), + getAssetName: assetContractController.getAssetName.bind( + assetContractController, + ), + getAssetSymbol: assetContractController.getAssetSymbol.bind( + assetContractController, + ), + getCollectibleTokenURI: assetContractController.getCollectibleTokenURI.bind( + assetContractController, + ), + }); + const currencyRateController = new CurrencyRateController(); + const controller = new ComposableController([ + new AddressBookController(), + assetController, assetContractController, - ), - getCollectibleTokenURI: assetContractController.getCollectibleTokenURI.bind( - assetContractController, - ), + new EnsController(), + currencyRateController, + networkController, + preferencesController, + new TokenRatesController({ + onAssetsStateChange: (listener) => + assetController.subscribe(listener), + onCurrencyRateStateChange: (listener) => + currencyRateController.subscribe(listener), + }), + ]); + expect(controller.state).toStrictEqual({ + AddressBookController: { addressBook: {} }, + AssetsContractController: {}, + AssetsController: { + allCollectibleContracts: {}, + allCollectibles: {}, + allTokens: {}, + collectibleContracts: [], + collectibles: [], + ignoredCollectibles: [], + ignoredTokens: [], + suggestedAssets: [], + tokens: [], + }, + CurrencyRateController: { + conversionDate: 0, + conversionRate: 0, + currentCurrency: 'usd', + nativeCurrency: 'ETH', + usdConversionRate: 0, + }, + EnsController: { + ensEntries: {}, + }, + NetworkController: { + network: 'loading', + provider: { type: 'mainnet', chainId: NetworksChainId.mainnet }, + }, + PreferencesController: { + featureFlags: {}, + frequentRpcList: [], + identities: {}, + ipfsGateway: 'https://ipfs.io/ipfs/', + lostIdentities: {}, + selectedAddress: '', + }, + TokenRatesController: { contractExchangeRates: {} }, + }); }); - const currencyRateController = new CurrencyRateController(); - const controller = new ComposableController([ - new AddressBookController(), - assetController, - assetContractController, - new EnsController(), - currencyRateController, - networkController, - preferencesController, - new TokenRatesController({ - onAssetsStateChange: (listener) => assetController.subscribe(listener), - onCurrencyRateStateChange: (listener) => - currencyRateController.subscribe(listener), - }), - ]); - expect(controller.state).toStrictEqual({ - AddressBookController: { addressBook: {} }, - AssetsContractController: {}, - AssetsController: { + + it('should compose flat controller state', () => { + const preferencesController = new PreferencesController(); + const networkController = new NetworkController(); + const assetContractController = new AssetsContractController(); + const assetController = new AssetsController({ + onPreferencesStateChange: (listener) => + preferencesController.subscribe(listener), + onNetworkStateChange: (listener) => + networkController.subscribe(listener), + getAssetName: assetContractController.getAssetName.bind( + assetContractController, + ), + getAssetSymbol: assetContractController.getAssetSymbol.bind( + assetContractController, + ), + getCollectibleTokenURI: assetContractController.getCollectibleTokenURI.bind( + assetContractController, + ), + }); + const currencyRateController = new CurrencyRateController(); + const controller = new ComposableController([ + new AddressBookController(), + assetController, + assetContractController, + new EnsController(), + currencyRateController, + networkController, + preferencesController, + new TokenRatesController({ + onAssetsStateChange: (listener) => + assetController.subscribe(listener), + onCurrencyRateStateChange: (listener) => + currencyRateController.subscribe(listener), + }), + ]); + expect(controller.flatState).toStrictEqual({ + addressBook: {}, allCollectibleContracts: {}, allCollectibles: {}, allTokens: {}, collectibleContracts: [], collectibles: [], - ignoredCollectibles: [], - ignoredTokens: [], - suggestedAssets: [], - tokens: [], - }, - CurrencyRateController: { + contractExchangeRates: {}, conversionDate: 0, conversionRate: 0, currentCurrency: 'usd', - nativeCurrency: 'ETH', - usdConversionRate: 0, - }, - EnsController: { ensEntries: {}, - }, - NetworkController: { - network: 'loading', - provider: { type: 'mainnet', chainId: NetworksChainId.mainnet }, - }, - PreferencesController: { featureFlags: {}, frequentRpcList: [], identities: {}, + ignoredCollectibles: [], + ignoredTokens: [], ipfsGateway: 'https://ipfs.io/ipfs/', lostIdentities: {}, + nativeCurrency: 'ETH', + network: 'loading', + provider: { type: 'mainnet', chainId: NetworksChainId.mainnet }, selectedAddress: '', - }, - TokenRatesController: { contractExchangeRates: {} }, - }); - }); - - it('should compose flat controller state', () => { - const preferencesController = new PreferencesController(); - const networkController = new NetworkController(); - const assetContractController = new AssetsContractController(); - const assetController = new AssetsController({ - onPreferencesStateChange: (listener) => - preferencesController.subscribe(listener), - onNetworkStateChange: (listener) => networkController.subscribe(listener), - getAssetName: assetContractController.getAssetName.bind( - assetContractController, - ), - getAssetSymbol: assetContractController.getAssetSymbol.bind( - assetContractController, - ), - getCollectibleTokenURI: assetContractController.getCollectibleTokenURI.bind( - assetContractController, - ), - }); - const currencyRateController = new CurrencyRateController(); - const controller = new ComposableController([ - new AddressBookController(), - assetController, - assetContractController, - new EnsController(), - currencyRateController, - networkController, - preferencesController, - new TokenRatesController({ - onAssetsStateChange: (listener) => assetController.subscribe(listener), - onCurrencyRateStateChange: (listener) => - currencyRateController.subscribe(listener), - }), - ]); - expect(controller.flatState).toStrictEqual({ - addressBook: {}, - allCollectibleContracts: {}, - allCollectibles: {}, - allTokens: {}, - collectibleContracts: [], - collectibles: [], - contractExchangeRates: {}, - conversionDate: 0, - conversionRate: 0, - currentCurrency: 'usd', - ensEntries: {}, - featureFlags: {}, - frequentRpcList: [], - identities: {}, - ignoredCollectibles: [], - ignoredTokens: [], - ipfsGateway: 'https://ipfs.io/ipfs/', - lostIdentities: {}, - nativeCurrency: 'ETH', - network: 'loading', - provider: { type: 'mainnet', chainId: NetworksChainId.mainnet }, - selectedAddress: '', - suggestedAssets: [], - tokens: [], - usdConversionRate: 0, + suggestedAssets: [], + tokens: [], + usdConversionRate: 0, + }); }); - }); - it('should set initial state', () => { - const state = { - addressBook: { - '0x1': { - '0x1234': { - address: 'bar', - chainId: '1', - isEns: false, - memo: '', - name: 'foo', - }, - }, - }, - }; - const controller = new ComposableController([ - new AddressBookController(undefined, state), - ]); - expect(controller.state).toStrictEqual({ AddressBookController: state }); - }); - - it('should notify listeners of nested state change', () => { - const addressBookController = new AddressBookController(); - const controller = new ComposableController([addressBookController]); - const listener = stub(); - controller.subscribe(listener); - addressBookController.set( - '0x32Be343B94f860124dC4fEe278FDCBD38C102D88', - 'foo', - ); - expect(listener.calledOnce).toBe(true); - expect(listener.getCall(0).args[0]).toStrictEqual({ - AddressBookController: { + it('should set initial state', () => { + const state = { addressBook: { - 1: { - '0x32Be343B94f860124dC4fEe278FDCBD38C102D88': { - address: '0x32Be343B94f860124dC4fEe278FDCBD38C102D88', + '0x1': { + '0x1234': { + address: 'bar', chainId: '1', isEns: false, memo: '', @@ -191,7 +240,310 @@ describe('ComposableController', () => { }, }, }, - }, + }; + const controller = new ComposableController([ + new AddressBookController(undefined, state), + ]); + expect(controller.state).toStrictEqual({ AddressBookController: state }); + }); + + it('should notify listeners of nested state change', () => { + const addressBookController = new AddressBookController(); + const controller = new ComposableController([addressBookController]); + const listener = stub(); + controller.subscribe(listener); + addressBookController.set( + '0x32Be343B94f860124dC4fEe278FDCBD38C102D88', + 'foo', + ); + expect(listener.calledOnce).toBe(true); + expect(listener.getCall(0).args[0]).toStrictEqual({ + AddressBookController: { + addressBook: { + 1: { + '0x32Be343B94f860124dC4fEe278FDCBD38C102D88': { + address: '0x32Be343B94f860124dC4fEe278FDCBD38C102D88', + chainId: '1', + isEns: false, + memo: '', + name: 'foo', + }, + }, + }, + }, + }); + }); + }); + + describe('BaseControllerV2', () => { + it('should compose controller state', () => { + const controllerMessenger = new ControllerMessenger< + never, + FooControllerEvent + >(); + const fooControllerMessenger = controllerMessenger.getRestricted< + 'FooController', + never, + never + >({ + name: 'FooController', + }); + const fooController = new FooController(fooControllerMessenger); + + const composableControllerMessenger = controllerMessenger.getRestricted< + 'ComposableController', + never, + 'FooController:stateChange' + >({ + name: 'ComposableController', + allowedEvents: ['FooController:stateChange'], + }); + const composableController = new ComposableController( + [fooController], + composableControllerMessenger, + ); + expect(composableController.state).toStrictEqual({ + FooController: { foo: 'foo' }, + }); + }); + + it('should compose flat controller state', () => { + const controllerMessenger = new ControllerMessenger< + never, + FooControllerEvent + >(); + const fooControllerMessenger = controllerMessenger.getRestricted< + 'FooController', + never, + never + >({ + name: 'FooController', + }); + const fooController = new FooController(fooControllerMessenger); + const composableControllerMessenger = controllerMessenger.getRestricted< + 'ComposableController', + never, + 'FooController:stateChange' + >({ + name: 'ComposableController', + allowedEvents: ['FooController:stateChange'], + }); + const composableController = new ComposableController( + [fooController], + composableControllerMessenger, + ); + expect(composableController.flatState).toStrictEqual({ + foo: 'foo', + }); + }); + + it('should notify listeners of nested state change', () => { + const controllerMessenger = new ControllerMessenger< + never, + FooControllerEvent + >(); + const fooControllerMessenger = controllerMessenger.getRestricted< + 'FooController', + never, + never + >({ + name: 'FooController', + }); + const fooController = new FooController(fooControllerMessenger); + const composableControllerMessenger = controllerMessenger.getRestricted< + 'ComposableController', + never, + 'FooController:stateChange' + >({ + name: 'ComposableController', + allowedEvents: ['FooController:stateChange'], + }); + const composableController = new ComposableController( + [fooController], + composableControllerMessenger, + ); + + const listener = stub(); + composableController.subscribe(listener); + fooController.updateFoo('bar'); + + expect(listener.calledOnce).toBe(true); + expect(listener.getCall(0).args[0]).toStrictEqual({ + FooController: { + foo: 'bar', + }, + }); + }); + }); + + describe('Mixed BaseController and BaseControllerV2', () => { + it('should compose controller state', () => { + const barController = new BarController(); + const controllerMessenger = new ControllerMessenger< + never, + FooControllerEvent + >(); + const fooControllerMessenger = controllerMessenger.getRestricted< + 'FooController', + never, + never + >({ + name: 'FooController', + }); + const fooController = new FooController(fooControllerMessenger); + const composableControllerMessenger = controllerMessenger.getRestricted< + 'ComposableController', + never, + 'FooController:stateChange' + >({ + name: 'ComposableController', + allowedEvents: ['FooController:stateChange'], + }); + const composableController = new ComposableController( + [barController, fooController], + composableControllerMessenger, + ); + expect(composableController.state).toStrictEqual({ + BarController: { bar: 'bar' }, + FooController: { foo: 'foo' }, + }); + }); + + it('should compose flat controller state', () => { + const barController = new BarController(); + const controllerMessenger = new ControllerMessenger< + never, + FooControllerEvent + >(); + const fooControllerMessenger = controllerMessenger.getRestricted< + 'FooController', + never, + never + >({ + name: 'FooController', + }); + const fooController = new FooController(fooControllerMessenger); + const composableControllerMessenger = controllerMessenger.getRestricted< + 'ComposableController', + never, + 'FooController:stateChange' + >({ + name: 'ComposableController', + allowedEvents: ['FooController:stateChange'], + }); + const composableController = new ComposableController( + [barController, fooController], + composableControllerMessenger, + ); + expect(composableController.flatState).toStrictEqual({ + bar: 'bar', + foo: 'foo', + }); + }); + + it('should notify listeners of BaseController state change', () => { + const barController = new BarController(); + const controllerMessenger = new ControllerMessenger< + never, + FooControllerEvent + >(); + const fooControllerMessenger = controllerMessenger.getRestricted< + 'FooController', + never, + never + >({ + name: 'FooController', + }); + const fooController = new FooController(fooControllerMessenger); + const composableControllerMessenger = controllerMessenger.getRestricted< + 'ComposableController', + never, + 'FooController:stateChange' + >({ + name: 'ComposableController', + allowedEvents: ['FooController:stateChange'], + }); + const composableController = new ComposableController( + [barController, fooController], + composableControllerMessenger, + ); + + const listener = stub(); + composableController.subscribe(listener); + barController.updateBar('foo'); + + expect(listener.calledOnce).toBe(true); + expect(listener.getCall(0).args[0]).toStrictEqual({ + BarController: { + bar: 'foo', + }, + FooController: { + foo: 'foo', + }, + }); + }); + + it('should notify listeners of BaseControllerV2 state change', () => { + const barController = new BarController(); + const controllerMessenger = new ControllerMessenger< + never, + FooControllerEvent + >(); + const fooControllerMessenger = controllerMessenger.getRestricted< + 'FooController', + never, + never + >({ + name: 'FooController', + }); + const fooController = new FooController(fooControllerMessenger); + const composableControllerMessenger = controllerMessenger.getRestricted< + 'ComposableController', + never, + 'FooController:stateChange' + >({ + name: 'ComposableController', + allowedEvents: ['FooController:stateChange'], + }); + const composableController = new ComposableController( + [barController, fooController], + composableControllerMessenger, + ); + + const listener = stub(); + composableController.subscribe(listener); + fooController.updateFoo('bar'); + + expect(listener.calledOnce).toBe(true); + expect(listener.getCall(0).args[0]).toStrictEqual({ + BarController: { + bar: 'bar', + }, + FooController: { + foo: 'bar', + }, + }); + }); + + it('should throw if controller messenger not provided', () => { + const barController = new BarController(); + const controllerMessenger = new ControllerMessenger< + never, + FooControllerEvent + >(); + const fooControllerMessenger = controllerMessenger.getRestricted< + 'FooController', + never, + never + >({ + name: 'FooController', + }); + const fooController = new FooController(fooControllerMessenger); + expect( + () => new ComposableController([barController, fooController]), + ).toThrow( + 'Messaging system required if any BaseControllerV2 controllers are used', + ); }); }); }); diff --git a/src/ComposableController.ts b/src/ComposableController.ts index 044e194cce..c30594c1e8 100644 --- a/src/ComposableController.ts +++ b/src/ComposableController.ts @@ -1,9 +1,19 @@ import BaseController from './BaseController'; +import { RestrictedControllerMessenger } from './ControllerMessenger'; /** * List of child controller instances + * + * This type encompasses controllers based up either BaseController or + * BaseControllerV2. The BaseControllerV2 type can't be included directly + * because the generic parameters it expects require knowing the exact state + * shape, so instead we look for an object with the BaseControllerV2 properties + * that we use in the ComposableController (name and state). */ -export type ControllerList = BaseController[]; +export type ControllerList = ( + | BaseController + | { name: string; state: Record } +)[]; /** * Controller that can be used to compose multiple controllers together @@ -11,6 +21,14 @@ export type ControllerList = BaseController[]; export class ComposableController extends BaseController { private controllers: ControllerList = []; + private messagingSystem?: RestrictedControllerMessenger< + 'ComposableController', + never, + any, + never, + any + >; + /** * Name of this controller used during composition */ @@ -20,9 +38,18 @@ export class ComposableController extends BaseController { * Creates a ComposableController instance * * @param controllers - Map of names to controller instances - * @param initialState - Initial state keyed by child controller name + * @param messenger - The controller messaging system, used for communicating with BaseControllerV2 controllers */ - constructor(controllers: ControllerList) { + constructor( + controllers: ControllerList, + messenger?: RestrictedControllerMessenger< + 'ComposableController', + never, + any, + never, + any + >, + ) { super( undefined, controllers.reduce((state, controller) => { @@ -32,11 +59,25 @@ export class ComposableController extends BaseController { ); this.initialize(); this.controllers = controllers; + this.messagingSystem = messenger; this.controllers.forEach((controller) => { const { name } = controller; - controller.subscribe((state) => { - this.update({ [name]: state }); - }); + if (controller instanceof BaseController) { + controller.subscribe((state) => { + this.update({ [name]: state }); + }); + } else if (this.messagingSystem) { + (this.messagingSystem.subscribe as any)( + `${name}:stateChange`, + (state: any) => { + this.update({ [name]: state }); + }, + ); + } else { + throw new Error( + `Messaging system required if any BaseControllerV2 controllers are used`, + ); + } }); }