Skip to content
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] Subscribe to stateChange events of V1 controllers with messenger, type fixes #3964

Merged
merged 12 commits into from
Mar 7, 2024
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
3 changes: 1 addition & 2 deletions packages/composable-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- Add and export functions `isBaseControllerV1` and `isBaseController`, which are type guards for validating controller instances ([#3904](https://github.com/MetaMask/core/pull/3904))
- Add and export types `BaseControllerV1Instance`, `BaseControllerV2Instance`, `ControllerInstance` which are the narrowest supertypes for all controllers extending from, respectively, `BaseControllerV1`, `BaseController`, and both ([#3904](https://github.com/MetaMask/core/pull/3904))

### Changed

- **BREAKING:** Passing a non-controller into `controllers` constructor option now throws an error ([#3904](https://github.com/MetaMask/core/pull/3904))
- **BREAKING:** The `AllowedActions` parameter of the `ComposableControllerMessenger` type is narrowed from `string` to `never`, as `ComposableController` does not use any external controller actions. ([#3904](https://github.com/MetaMask/core/pull/3904))
- Add `@metamask/utils` ^8.3.0 as a dependency. ([#3904](https://github.com/MetaMask/core/pull/3904))
- Subscribe to the `stateChange` events of `BaseControllerV1` controllers that have a `messagingSystem` class field with an assigned instance of the `RestrictedControllerMessenger` class. ([#3964](https://github.com/MetaMask/core/pull/3964))

### Removed

Expand Down
3 changes: 1 addition & 2 deletions packages/composable-controller/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,7 @@
"test:watch": "jest --watch"
},
"dependencies": {
"@metamask/base-controller": "^4.1.1",
"@metamask/utils": "^8.3.0"
"@metamask/base-controller": "^4.1.1"
},
"devDependencies": {
"@metamask/auto-changelog": "^3.4.4",
Expand Down
111 changes: 62 additions & 49 deletions packages/composable-controller/src/ComposableController.ts
Original file line number Diff line number Diff line change
@@ -1,51 +1,57 @@
import { BaseController, BaseControllerV1 } from '@metamask/base-controller';
import type {
RestrictedControllerMessenger,
BaseState,
ActionConstraint,
BaseConfig,
StateMetadata,
BaseState,
EventConstraint,
RestrictedControllerMessenger,
StateConstraint,
} from '@metamask/base-controller';
import { isValidJson } from '@metamask/utils';
import type { Patch } from 'immer';

export const controllerName = 'ComposableController';

// TODO: Remove this type once `BaseControllerV2` migrations are completed for all controllers.
/**
* A type encompassing all controller instances that extend from `BaseControllerV1`.
* A universal subtype of all controller instances that extend from `BaseControllerV1`.
* Any `BaseControllerV1` instance can be assigned to this type.
*
* Note that this type is not the greatest subtype or narrowest supertype of all `BaseControllerV1` instances.
* This type is therefore unsuitable for general use as a type constraint, and is only intended for use within the ComposableController.
*/
export type BaseControllerV1Instance =
// `any` is used to include all `BaseControllerV1` instances.
// `any` is used so that all `BaseControllerV1` instances are assignable to this type.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
BaseControllerV1<any, any>;

/**
* A type encompassing all controller instances that extend from `BaseController` (formerly `BaseControllerV2`).
* A universal subtype of all controller instances that extend from `BaseController` (formerly `BaseControllerV2`).
* Any `BaseController` instance can be assigned to this type.
*
* 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.
* Note that this type is not the greatest subtype or narrowest supertype of all `BaseController` instances.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍🏻

* This type is therefore unsuitable for general use as a type constraint, and is only intended for use within the ComposableController.
*
* Instead, we look for an object with the `BaseController` properties that we use in the ComposableController (name and state).
* For this reason, we only look for `BaseController` properties that we use in the ComposableController (name and state).
*/
export type BaseControllerV2Instance = {
export type BaseControllerInstance = {
name: string;
state: StateConstraint;
};

// TODO: Remove `BaseControllerV1Instance` member once `BaseControllerV2` migrations are completed for all controllers.
/**
* A type encompassing all controller instances that extend from `BaseControllerV1` or `BaseController`.
* A universal subtype of all controller instances that extend from `BaseController` (formerly `BaseControllerV2`) or `BaseControllerV1`.
* Any `BaseController` or `BaseControllerV1` instance can be assigned to this type.
*
* Note that this type is not the greatest subtype or narrowest supertype of all `BaseController` and `BaseControllerV1` instances.
* This type is therefore unsuitable for general use as a type constraint, and is only intended for use within the ComposableController.
*/
export type ControllerInstance =
| BaseControllerV1Instance
| BaseControllerV2Instance;
| BaseControllerInstance;

/**
* Determines if the given controller is an instance of BaseControllerV1
* 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.
* @returns True if the controller is an instance of `BaseControllerV1`
*/
export function isBaseControllerV1(
controller: ControllerInstance,
Expand All @@ -67,13 +73,23 @@ export function isBaseControllerV1(
}

/**
* Determines if the given controller is an instance of BaseController
* 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
* @returns True if the controller is an instance of `BaseController`
*/
export function isBaseController(
controller: ControllerInstance,
): controller is BaseController<never, never, never> {
): controller is BaseController<
string,
StateConstraint,
RestrictedControllerMessenger<
string,
ActionConstraint,
EventConstraint,
string,
string
>
> {
return (
'name' in controller &&
typeof controller.name === 'string' &&
Expand All @@ -83,10 +99,10 @@ export function isBaseController(
);
}

// TODO: Replace `any` with `Json` once `BaseControllerV2` migrations are completed for all controllers.
export type ComposableControllerState = {
// `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>;
};
Expand Down Expand Up @@ -142,7 +158,7 @@ export class ComposableController extends BaseController<

super({
name: controllerName,
metadata: controllers.reduce<StateMetadata<ComposableControllerState>>(
metadata: controllers.reduce(
(metadata, controller) => ({
...metadata,
[controller.name]: isBaseController(controller)
Expand All @@ -151,12 +167,9 @@ export class ComposableController extends BaseController<
}),
{},
),
state: controllers.reduce<ComposableControllerState>(
(state, controller) => {
return { ...state, [controller.name]: controller.state };
},
{},
),
state: controllers.reduce((state, controller) => {
return { ...state, [controller.name]: controller.state };
}, {}),
messenger,
});

Expand All @@ -168,33 +181,33 @@ export class ComposableController extends BaseController<
/**
* 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 {
if (!isBaseController(controller) && !isBaseControllerV1(controller)) {
throw new Error(
'Invalid controller: controller must extend from BaseController or BaseControllerV1',
);
}

const { name } = controller;
if (isBaseControllerV1(controller)) {
controller.subscribe((childState) => {
this.update((state) => ({
...state,
[name]: childState,
}));
});
} else if (isBaseController(controller)) {
if (
(isBaseControllerV1(controller) && 'messagingSystem' in controller) ||
isBaseController(controller)
) {
this.messagingSystem.subscribe(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a BaseControllerV1 controller has a messagingSystem property, could the state of this controller could be updated twice per update? If so, do we still need an else if?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great point! Fixed here: 811a94a

`${name}:stateChange`,
(childState: StateConstraint) => {
if (isValidJson(childState)) {
this.update((state) => ({
...state,
[name]: childState,
}));
}
(childState: Record<string, unknown>) => {
this.update((state) => {
Object.assign(state, { [name]: childState });
});
},
);
} else {
throw new Error(
'Invalid controller: controller must extend from BaseController or BaseControllerV1',
);
} else if (isBaseControllerV1(controller)) {
controller.subscribe((childState) => {
this.update((state) => {
Object.assign(state, { [name]: childState });
});
});
}
}
}
Expand Down
3 changes: 0 additions & 3 deletions packages/composable-controller/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
export type {
BaseControllerV1Instance,
BaseControllerV2Instance,
ControllerInstance,
ComposableControllerState,
ComposableControllerStateChangeEvent,
ComposableControllerEvents,
Expand Down
1 change: 0 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1712,7 +1712,6 @@ __metadata:
"@metamask/auto-changelog": ^3.4.4
"@metamask/base-controller": ^4.1.1
"@metamask/json-rpc-engine": ^7.3.3
"@metamask/utils": ^8.3.0
"@types/jest": ^27.4.1
deepmerge: ^4.2.2
immer: ^9.0.6
Expand Down
Loading