diff --git a/app/scripts/lib/ComposableObservableStore.test.ts b/app/scripts/lib/ComposableObservableStore.test.ts index 7abc87cb52b6..bb6b0c851a3b 100644 --- a/app/scripts/lib/ComposableObservableStore.test.ts +++ b/app/scripts/lib/ComposableObservableStore.test.ts @@ -3,10 +3,26 @@ import { BaseControllerV1, BaseController, ControllerMessenger, + BaseConfig, + BaseState, + RestrictedControllerMessengerConstraint, + ActionConstraint, + EventConstraint, } from '@metamask/base-controller'; import ComposableObservableStore from './ComposableObservableStore'; +import { + MemStoreControllers, + MemStoreControllersComposedState, +} from '../../../shared/types/metamask'; + +type OldExampleControllerState = { + baz: string; +}; -class OldExampleController extends BaseControllerV1 { +class OldExampleController extends BaseControllerV1< + BaseConfig & object, + BaseState & OldExampleControllerState +> { name = 'OldExampleController'; defaultState = { @@ -18,11 +34,22 @@ class OldExampleController extends BaseControllerV1 { this.initialize(); } - updateBaz(contents) { + updateBaz( + contents: OldExampleControllerState[keyof OldExampleControllerState], + ) { this.update({ baz: contents }); } } -class ExampleController extends BaseController { + +type ExampleControllerState = { + bar: string; + baz: string; +}; +class ExampleController extends BaseController< + 'ExampleController', + ExampleControllerState, + RestrictedControllerMessengerConstraint<'ExampleController'> +> { static defaultState = { bar: 'bar', baz: 'baz', @@ -33,7 +60,11 @@ class ExampleController extends BaseController { baz: { persist: false, anonymous: true }, }; - constructor({ messenger }) { + constructor({ + messenger, + }: { + messenger: RestrictedControllerMessengerConstraint<'ExampleController'>; + }) { super({ messenger, name: 'ExampleController', @@ -42,7 +73,7 @@ class ExampleController extends BaseController { }); } - updateBar(contents) { + updateBar(contents: ExampleControllerState[keyof ExampleControllerState]) { this.update((state) => { state.bar = contents; }); @@ -51,49 +82,79 @@ class ExampleController extends BaseController { describe('ComposableObservableStore', () => { it('should register initial state', () => { - const controllerMessenger = new ControllerMessenger(); + const controllerMessenger = new ControllerMessenger< + ActionConstraint, + EventConstraint + >(); const store = new ComposableObservableStore({ controllerMessenger, + // @ts-expect-error Intentionally passing in mock value for testing state: 'state', }); expect(store.getState()).toStrictEqual('state'); }); it('should register initial structure', () => { - const controllerMessenger = new ControllerMessenger(); - const testStore = new ObservableStore(); + const controllerMessenger = new ControllerMessenger< + ActionConstraint, + EventConstraint + >(); + const testStore = { store: new ObservableStore({}) }; const store = new ComposableObservableStore({ + // @ts-expect-error Intentionally passing in mock value for testing config: { TestStore: testStore }, controllerMessenger, }); - testStore.putState('state'); + testStore.store.putState('state'); expect(store.getState()).toStrictEqual({ TestStore: 'state' }); }); it('should update structure with observable store', () => { - const controllerMessenger = new ControllerMessenger(); - const testStore = new ObservableStore(); - const store = new ComposableObservableStore({ controllerMessenger }); + const controllerMessenger = new ControllerMessenger< + ActionConstraint, + EventConstraint + >(); + const testStore = { store: new ObservableStore({}) }; + const store = new ComposableObservableStore({ + controllerMessenger, + }); + // @ts-expect-error Intentionally passing in mock value for testing store.updateStructure({ TestStore: testStore }); - testStore.putState('state'); + testStore.store.putState('state'); expect(store.getState()).toStrictEqual({ TestStore: 'state' }); }); it('should update structure with BaseControllerV1-based controller', () => { - const controllerMessenger = new ControllerMessenger(); + const controllerMessenger = new ControllerMessenger< + ActionConstraint, + EventConstraint + >(); const oldExampleController = new OldExampleController(); - const store = new ComposableObservableStore({ controllerMessenger }); + const store = new ComposableObservableStore({ + controllerMessenger, + }); + // @ts-expect-error Intentionally passing in mock value for testing store.updateStructure({ OldExample: oldExampleController }); oldExampleController.updateBaz('state'); expect(store.getState()).toStrictEqual({ OldExample: { baz: 'state' } }); }); it('should update structure with BaseController-based controller', () => { - const controllerMessenger = new ControllerMessenger(); + const controllerMessenger = new ControllerMessenger< + ActionConstraint, + EventConstraint + >(); const exampleController = new ExampleController({ - messenger: controllerMessenger, + messenger: controllerMessenger.getRestricted({ + name: 'ExampleController', + allowedActions: [], + allowedEvents: [], + }), + }); + const store = new ComposableObservableStore({ + controllerMessenger, }); - const store = new ComposableObservableStore({ controllerMessenger }); + // @ts-expect-error Intentionally passing in mock value for testing store.updateStructure({ Example: exampleController }); exampleController.updateBar('state'); expect(store.getState()).toStrictEqual({ @@ -102,19 +163,29 @@ describe('ComposableObservableStore', () => { }); it('should update structure with all three types of stores', () => { - const controllerMessenger = new ControllerMessenger(); - const exampleStore = new ObservableStore(); + const controllerMessenger = new ControllerMessenger< + ActionConstraint, + EventConstraint + >(); + const exampleStore = { store: new ObservableStore({}) }; const exampleController = new ExampleController({ - messenger: controllerMessenger, + messenger: controllerMessenger.getRestricted({ + name: 'ExampleController', + allowedActions: [], + allowedEvents: [], + }), }); const oldExampleController = new OldExampleController(); - const store = new ComposableObservableStore({ controllerMessenger }); + const store = new ComposableObservableStore({ + controllerMessenger, + }); store.updateStructure({ + // @ts-expect-error Intentionally passing in mock value for testing Example: exampleController, OldExample: oldExampleController, Store: exampleStore, }); - exampleStore.putState('state'); + exampleStore.store.putState('state'); exampleController.updateBar('state'); oldExampleController.updateBaz('state'); expect(store.getState()).toStrictEqual({ @@ -125,18 +196,28 @@ describe('ComposableObservableStore', () => { }); it('should initialize state with all three types of stores', () => { - const controllerMessenger = new ControllerMessenger(); - const exampleStore = new ObservableStore(); + const controllerMessenger = new ControllerMessenger< + ActionConstraint, + EventConstraint + >(); + const exampleStore = { store: new ObservableStore({}) }; const exampleController = new ExampleController({ - messenger: controllerMessenger, + messenger: controllerMessenger.getRestricted({ + name: 'ExampleController', + allowedActions: [], + allowedEvents: [], + }), }); const oldExampleController = new OldExampleController(); - exampleStore.putState('state'); + exampleStore.store.putState('state'); exampleController.updateBar('state'); oldExampleController.updateBaz('state'); - const store = new ComposableObservableStore({ controllerMessenger }); + const store = new ComposableObservableStore({ + controllerMessenger, + }); store.updateStructure({ + // @ts-expect-error Intentionally passing in mock value for testing Example: exampleController, OldExample: oldExampleController, Store: exampleStore, @@ -150,12 +231,18 @@ describe('ComposableObservableStore', () => { }); it('should initialize falsy state', () => { - const controllerMessenger = new ControllerMessenger(); - const exampleStore = new ObservableStore(); - exampleStore.putState(false); - const store = new ComposableObservableStore({ controllerMessenger }); + const controllerMessenger = new ControllerMessenger< + ActionConstraint, + EventConstraint + >(); + const exampleStore = { store: new ObservableStore({}) }; + exampleStore.store.putState(false); + const store = new ComposableObservableStore({ + controllerMessenger, + }); store.updateStructure({ + // @ts-expect-error Intentionally passing in mock value for testing Example: exampleStore, }); @@ -165,13 +252,20 @@ describe('ComposableObservableStore', () => { }); it('should strip non-persisted state from initial state with all three types of stores', () => { - const controllerMessenger = new ControllerMessenger(); - const exampleStore = new ObservableStore(); + const controllerMessenger = new ControllerMessenger< + ActionConstraint, + EventConstraint + >(); + const exampleStore = { store: new ObservableStore({}) }; const exampleController = new ExampleController({ - messenger: controllerMessenger, + messenger: controllerMessenger.getRestricted({ + name: 'ExampleController', + allowedActions: [], + allowedEvents: [], + }), }); const oldExampleController = new OldExampleController(); - exampleStore.putState('state'); + exampleStore.store.putState('state'); exampleController.updateBar('state'); oldExampleController.updateBaz('state'); const store = new ComposableObservableStore({ @@ -180,6 +274,7 @@ describe('ComposableObservableStore', () => { }); store.updateStructure({ + // @ts-expect-error Intentionally passing in mock value for testing Example: exampleController, OldExample: oldExampleController, Store: exampleStore, @@ -192,48 +287,34 @@ describe('ComposableObservableStore', () => { }); }); - it('should return flattened state', () => { - const controllerMessenger = new ControllerMessenger(); - const fooStore = new ObservableStore({ foo: 'foo' }); - const barController = new ExampleController({ - messenger: controllerMessenger, - }); - const bazController = new OldExampleController(); + it('should return empty state when not configured', () => { + const controllerMessenger = new ControllerMessenger< + ActionConstraint, + EventConstraint + >(); const store = new ComposableObservableStore({ - config: { - FooStore: fooStore, - BarStore: barController, - BazStore: bazController, - }, controllerMessenger, - state: { - FooStore: fooStore.getState(), - BarStore: barController.state, - BazStore: bazController.state, - }, }); - expect(store.getFlatState()).toStrictEqual({ - foo: 'foo', - bar: 'bar', - baz: 'baz', - }); - }); - - it('should return empty flattened state when not configured', () => { - const controllerMessenger = new ControllerMessenger(); - const store = new ComposableObservableStore({ controllerMessenger }); - expect(store.getFlatState()).toStrictEqual({}); + expect(store.getState()).toStrictEqual({}); }); it('should throw if the controller messenger is omitted and the config includes a BaseControllerV2 controller', () => { - const controllerMessenger = new ControllerMessenger(); + const controllerMessenger = new ControllerMessenger< + ActionConstraint, + EventConstraint + >(); const exampleController = new ExampleController({ - messenger: controllerMessenger, + messenger: controllerMessenger.getRestricted({ + name: 'ExampleController', + allowedActions: [], + allowedEvents: [], + }), }); expect( () => new ComposableObservableStore({ config: { + // @ts-expect-error Intentionally passing in mock value for testing Example: exampleController, }, }), @@ -241,22 +322,35 @@ describe('ComposableObservableStore', () => { }); it('should throw if the controller messenger is omitted and updateStructure called with a BaseControllerV2 controller', () => { - const controllerMessenger = new ControllerMessenger(); + const controllerMessenger = new ControllerMessenger< + ActionConstraint, + EventConstraint + >(); const exampleController = new ExampleController({ - messenger: controllerMessenger, + messenger: controllerMessenger.getRestricted({ + name: 'ExampleController', + allowedActions: [], + allowedEvents: [], + }), }); + // @ts-expect-error Intentionally passing in invalid input for testing// @ts-expect-error Intentionally passing in invalid input for testing const store = new ComposableObservableStore({}); + // @ts-expect-error Intentionally passing in mock value for testing expect(() => store.updateStructure({ Example: exampleController })).toThrow( `Cannot read properties of undefined (reading 'subscribe')`, ); }); it('should throw if initialized with undefined config entry', () => { - const controllerMessenger = new ControllerMessenger(); + const controllerMessenger = new ControllerMessenger< + ActionConstraint, + EventConstraint + >(); expect( () => new ComposableObservableStore({ config: { + // @ts-expect-error Intentionally passing in mock value for testing Example: undefined, }, controllerMessenger, diff --git a/app/scripts/lib/ComposableObservableStore.ts b/app/scripts/lib/ComposableObservableStore.ts index 3ed244f5eb47..4fbe2c422027 100644 --- a/app/scripts/lib/ComposableObservableStore.ts +++ b/app/scripts/lib/ComposableObservableStore.ts @@ -1,13 +1,15 @@ import { ObservableStore } from '@metamask/obs-store'; import { ActionConstraint, + BaseState, ControllerMessenger, EventConstraint, getPersistentState, + isBaseController, + isBaseControllerV1, } from '@metamask/base-controller'; import { getKnownPropertyNames } from '@metamask/utils'; import { - BackgroundStateProxy, MemStoreControllers, MemStoreControllersComposedState, } from '../../../shared/types/metamask'; @@ -15,7 +17,9 @@ import { /** * An ObservableStore that can compose the state objects of its child stores and controllers */ -export default class ComposableObservableStore extends ObservableStore { +export default class ComposableObservableStore extends ObservableStore< + Partial +> { /** * Describes which stores are being composed. The key is the name of the * store, and the value is either an ObservableStore, or a controller that @@ -38,13 +42,13 @@ export default class ComposableObservableStore extends ObservableStore; - state: BackgroundStateProxy; - persist: boolean; + state?: Partial; + persist?: boolean; }) { super(state); this.persist = persist; @@ -58,62 +62,83 @@ export default class ComposableObservableStore extends ObservableStore} config - Describes which stores are being - * composed. The key is the name of the store, and the value is either an - * ObservableStore, or a controller that extends one of the two base + * composed. The key is the name of the store, and the value is either a controller + * with an `ObservableStore`-type `store` propeety, or a controller that extends one of the two base * controllers in the `@metamask/base-controller` package. */ updateStructure(config: MemStoreControllers) { this.config = config; this.removeAllListeners(); - const initialState = {}; - for (const controllerKey of getKnownPropertyNames(config)) { - const controller = config[controllerKey]; - if (!controller) { - throw new Error(`Undefined '${controllerKey}'`); - } + const initialState = getKnownPropertyNames( + config, + ).reduce( + (composedState, controllerKey) => { + const controller = config[controllerKey]; + if (!controller) { + throw new Error(`Undefined '${controllerKey}'`); + } - if ('store' in controller && Boolean(controller.store?.subscribe)) { - const { store } = controller; - store.subscribe( - (state: MemStoreControllersComposedState[typeof controllerKey]) => { + if ('store' in controller && Boolean(controller.store?.subscribe)) { + const { store } = controller; + store.subscribe( + (state: MemStoreControllersComposedState[typeof controllerKey]) => { + this.#onStateChange(controllerKey, state); + }, + ); + // @ts-expect-error TODO: Widen `isBaseControllerV1` input type to `unknown` + } else if (isBaseControllerV1(controller)) { + controller.subscribe((state) => { + // @ts-expect-error V2 controller state excluded by type guard this.#onStateChange(controllerKey, state); - }, - ); - } else { - this.controllerMessenger.subscribe<`${typeof controllerKey}:stateChange`>( - `${controllerKey}:stateChange`, - // @ts-expect-error TODO: Fix `handler` being typed as `never` by defining `Global{Actions,Events}` types and supplying them to `MetamaskController['controllerMessenger']` - (state: MemStoreControllersComposedState[typeof controllerKey]) => { - let updatedState: Partial< - MemStoreControllersComposedState[typeof controllerKey] - > = state; - if (this.persist && 'metadata' in controller) { - updatedState = getPersistentState( - // @ts-expect-error No state object can be passed into this parameter because its type is wider than all V2 state objects. - // TODO: Fix this parameter's type to be the widest subtype of V2 controller state types instead of their supertype/constraint. - state, - controller.metadata, - ) as Partial< - MemStoreControllersComposedState[typeof controllerKey] - >; - } - this.#onStateChange(controllerKey, updatedState); - }, - ); - } + }); + } + // @ts-expect-error TODO: Widen `isBaseController{,V1}` input types to `unknown` + if (isBaseController(controller) || isBaseControllerV1(controller)) { + try { + this.controllerMessenger.subscribe<`${typeof controller.name}:stateChange`>( + `${controller.name}:stateChange`, + // @ts-expect-error TODO: Fix `handler` being typed as `never` by defining `Global{Actions,Events}` types and supplying them to `MetamaskController['controllerMessenger']` + ( + state: MemStoreControllersComposedState[typeof controllerKey], + ) => { + let updatedState: Partial< + MemStoreControllersComposedState[typeof controllerKey] + > = state; + if (this.persist && 'metadata' in controller) { + updatedState = getPersistentState( + // @ts-expect-error No state object can be passed into this parameter because its type is wider than all V2 state objects. + // TODO: Fix this parameter's type to be the widest subtype of V2 controller state types instead of their supertype/constraint. + state, + controller.metadata, + ) as Partial< + MemStoreControllersComposedState[typeof controllerKey] + >; + } + this.#onStateChange(controllerKey, updatedState); + }, + ); + } catch (e) { + throw new Error( + `Cannot read properties of undefined (reading 'subscribe')`, + ); + } + } - const initialState = - 'store' in controller && 'subscribe' in controller.store - ? controller.store.getState?.() - : 'state' in controller - ? controller.state - : undefined; + const controllerState = + 'store' in controller && 'subscribe' in controller.store + ? controller.store.getState?.() + : 'state' in controller + ? controller.state + : undefined; - initialState[controllerKey] = - this.persist && 'metadata' in controller && controller.metadata - ? getPersistentState(initialState, controller.metadata) - : initialState; - } + composedState[controllerKey] = + this.persist && 'metadata' in controller && controller.metadata + ? getPersistentState(controllerState, controller.metadata) + : controllerState; + return composedState; + }, + {} as never, + ); this.updateState(initialState); }