-
-
Notifications
You must be signed in to change notification settings - Fork 192
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[composable-controller] Better typing for state, messenger, strict type checks for inputs, remove #controllers
field
#3904
Changes from all commits
3344a44
f50716a
7be4f28
416382e
7b9e33a
d4a8161
c762c6e
53be427
4f54841
f4530ab
724f80e
48669b2
1069fa4
d4cf789
559b4a1
71fc9a4
f7097cb
ada78a2
68f2a04
523f746
3b687f4
c5edafd
9e7a625
8c5acce
856001b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,57 +2,114 @@ import { BaseController, BaseControllerV1 } from '@metamask/base-controller'; | |
import type { | ||
ControllerStateChangeEvent, | ||
RestrictedControllerMessenger, | ||
BaseState, | ||
BaseConfig, | ||
StateMetadata, | ||
} from '@metamask/base-controller'; | ||
import { isValidJson, type Json } from '@metamask/utils'; | ||
|
||
export const controllerName = 'ComposableController'; | ||
|
||
/* | ||
* This type encompasses controllers based on either BaseControllerV1 or | ||
* BaseController. The BaseController 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 BaseController properties | ||
* that we use in the ComposableController (name and state). | ||
// TODO: Remove this type once `BaseControllerV2` migrations are completed for all controllers. | ||
/** | ||
* A type encompassing all controller instances that extend from `BaseControllerV1`. | ||
*/ | ||
type ControllerInstance = | ||
// TODO: Replace `any` with type | ||
export type BaseControllerV1Instance = | ||
// `any` is used to include all `BaseControllerV1` instances. | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
BaseControllerV1<any, any> | { name: string; state: Record<string, unknown> }; | ||
BaseControllerV1<any, any>; | ||
|
||
/** | ||
* List of child controller instances | ||
* A type encompassing all controller instances that extend from `BaseController` (formerly `BaseControllerV2`). | ||
* | ||
* The `BaseController` class itself can't be used directly as a type representing all of its subclasses, | ||
* because the generic parameters it expects require knowing the exact shape of the controller's state and messenger. | ||
* | ||
* Instead, we look for an object with the `BaseController` properties that we use in the ComposableController (name and state). | ||
*/ | ||
export type ControllerList = ControllerInstance[]; | ||
export type BaseControllerV2Instance = { | ||
name: string; | ||
state: Record<string, Json>; | ||
}; | ||
|
||
// TODO: Remove `BaseControllerV1Instance` member once `BaseControllerV2` migrations are completed for all controllers. | ||
/** | ||
* A type encompassing all controller instances that extend from `BaseControllerV1` or `BaseController`. | ||
*/ | ||
export type ControllerInstance = | ||
| BaseControllerV1Instance | ||
| BaseControllerV2Instance; | ||
|
||
/** | ||
* Determines if the given controller is an instance of BaseControllerV1 | ||
* @param controller - Controller instance to check | ||
* @returns True if the controller is an instance of BaseControllerV1 | ||
* TODO: Deprecate once `BaseControllerV2` migrations are completed for all controllers. | ||
*/ | ||
function isBaseControllerV1( | ||
export function isBaseControllerV1( | ||
controller: ControllerInstance, | ||
// TODO: Replace `any` with type | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
): controller is BaseControllerV1<any, any> { | ||
return controller instanceof BaseControllerV1; | ||
): controller is BaseControllerV1< | ||
BaseConfig & Record<string, unknown>, | ||
BaseState & Record<string, unknown> | ||
> { | ||
return ( | ||
'name' in controller && | ||
typeof controller.name === 'string' && | ||
'defaultConfig' in controller && | ||
typeof controller.defaultConfig === 'object' && | ||
'defaultState' in controller && | ||
typeof controller.defaultState === 'object' && | ||
'disabled' in controller && | ||
typeof controller.disabled === 'boolean' && | ||
controller instanceof BaseControllerV1 | ||
); | ||
} | ||
|
||
/** | ||
* Determines if the given controller is an instance of BaseController | ||
* @param controller - Controller instance to check | ||
* @returns True if the controller is an instance of BaseController | ||
*/ | ||
export function isBaseController( | ||
controller: ControllerInstance, | ||
): controller is BaseController<never, never, never> { | ||
return ( | ||
'name' in controller && | ||
typeof controller.name === 'string' && | ||
'state' in controller && | ||
typeof controller.state === 'object' && | ||
controller instanceof BaseController | ||
); | ||
Comment on lines
+51
to
+82
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added redundant property checks for extra runtime safety. |
||
} | ||
|
||
export type ComposableControllerState = { | ||
[name: string]: ControllerInstance['state']; | ||
// `any` is used here to disable the `BaseController` type constraint which expects state properties to extend `Record<string, Json>`. | ||
// `ComposableController` state needs to accommodate `BaseControllerV1` state objects that may have properties wider than `Json`. | ||
// TODO: Replace `any` with `Json` once `BaseControllerV2` migrations are completed for all controllers. | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
[name: string]: Record<string, any>; | ||
}; | ||
|
||
export type ComposableControllerStateChangeEvent = ControllerStateChangeEvent< | ||
typeof controllerName, | ||
ComposableControllerState | ||
Record<string, unknown> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ideally, this would be something that covers the state types of both BaseController versions: Record<string, BaseState & Record<string, unknown> | Record<string, Json>> But this results in an implicit any error, and it conflicts with the messenger typing for allowed events. Using To be revisited once |
||
>; | ||
|
||
export type ComposableControllerEvents = ComposableControllerStateChangeEvent; | ||
|
||
type AnyControllerStateChangeEvent = ControllerStateChangeEvent< | ||
string, | ||
Record<string, unknown> | ||
>; | ||
|
||
type AllowedEvents = AnyControllerStateChangeEvent; | ||
|
||
export type ComposableControllerMessenger = RestrictedControllerMessenger< | ||
typeof controllerName, | ||
never, | ||
ControllerStateChangeEvent<string, Record<string, unknown>>, | ||
string, | ||
string | ||
ComposableControllerEvents | AllowedEvents, | ||
never, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No reason to keep this as |
||
AllowedEvents['type'] | ||
>; | ||
|
||
/** | ||
|
@@ -63,8 +120,6 @@ export class ComposableController extends BaseController< | |
ComposableControllerState, | ||
ComposableControllerMessenger | ||
> { | ||
readonly #controllers: ControllerList = []; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Alternatively, we could keep the if (!this.state[name]) {
this.#controllers.push(controller)
} I prefer removing the field because it makes it unambiguous that child controllers aren't meant to be added, and their state and subscriptions aren't meant to be mutated after instantiation. Also, having ComposableController depend on mutable internal state would introduce complications for #3627. |
||
|
||
/** | ||
* Creates a ComposableController instance. | ||
* | ||
|
@@ -77,7 +132,7 @@ export class ComposableController extends BaseController< | |
controllers, | ||
messenger, | ||
}: { | ||
controllers: ControllerList; | ||
controllers: ControllerInstance[]; | ||
messenger: ComposableControllerMessenger; | ||
}) { | ||
if (messenger === undefined) { | ||
|
@@ -86,23 +141,33 @@ export class ComposableController extends BaseController< | |
|
||
super({ | ||
name: controllerName, | ||
metadata: {}, | ||
state: controllers.reduce((state, controller) => { | ||
return { ...state, [controller.name]: controller.state }; | ||
}, {} as ComposableControllerState), | ||
metadata: controllers.reduce<StateMetadata<ComposableControllerState>>( | ||
(metadata, controller) => ({ | ||
...metadata, | ||
[controller.name]: isBaseController(controller) | ||
? controller.metadata | ||
: { persist: true, anonymous: true }, | ||
}), | ||
{}, | ||
), | ||
state: controllers.reduce<ComposableControllerState>( | ||
(state, controller) => { | ||
return { ...state, [controller.name]: controller.state }; | ||
}, | ||
{}, | ||
), | ||
messenger, | ||
}); | ||
|
||
this.#controllers = controllers; | ||
this.#controllers.forEach((controller) => | ||
controllers.forEach((controller) => | ||
this.#updateChildController(controller), | ||
); | ||
} | ||
|
||
/** | ||
* Adds a child controller instance to composable controller state, | ||
* or updates the state of a child controller. | ||
* Constructor helper that subscribes to child controller state changes. | ||
* @param controller - Controller instance to update | ||
* TODO: Remove `isBaseControllerV1` branch once `BaseControllerV2` migrations are completed for all controllers. | ||
*/ | ||
#updateChildController(controller: ControllerInstance): void { | ||
const { name } = controller; | ||
|
@@ -113,15 +178,18 @@ export class ComposableController extends BaseController< | |
[name]: childState, | ||
})); | ||
}); | ||
} else { | ||
this.messagingSystem.subscribe( | ||
`${String(name)}:stateChange`, | ||
(childState: Record<string, unknown>) => { | ||
} else if (isBaseController(controller)) { | ||
this.messagingSystem.subscribe(`${name}:stateChange`, (childState) => { | ||
if (isValidJson(childState)) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since the child controller is validated as BaseControllerV2, its state type is guaranteed to be |
||
this.update((state) => ({ | ||
...state, | ||
[name]: childState, | ||
})); | ||
}, | ||
} | ||
}); | ||
} else { | ||
throw new Error( | ||
'Invalid controller: controller must extend from BaseController or BaseControllerV1', | ||
Comment on lines
+191
to
+192
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Test case added for this branch. |
||
); | ||
} | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,8 +1,14 @@ | ||
export type { | ||
ControllerList, | ||
BaseControllerV1Instance, | ||
BaseControllerV2Instance, | ||
ControllerInstance, | ||
ComposableControllerState, | ||
ComposableControllerStateChangeEvent, | ||
ComposableControllerEvents, | ||
ComposableControllerMessenger, | ||
} from './ComposableController'; | ||
export { ComposableController } from './ComposableController'; | ||
export { | ||
ComposableController, | ||
isBaseController, | ||
isBaseControllerV1, | ||
} from './ComposableController'; |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -6,6 +6,9 @@ | |
"references": [ | ||
{ | ||
"path": "../base-controller" | ||
}, | ||
{ | ||
"path": "../json-rpc-engine" | ||
} | ||
Comment on lines
+10
to
12
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we also want to add There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed here: 5da0893 Will a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If it's only being used for tests, then you're correct, I don't think adding it to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No errors on yarn build! Think we're good to go. |
||
], | ||
"include": ["../../types", "./src"] | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The BaseControllerV1
childState
in#updateChildController
is correctly inferred toBaseState & Record<string, unknown>
.