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] Better typing for state, messenger, strict type checks for inputs, remove #controllers field #3904

Merged
merged 25 commits into from
Feb 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
3344a44
Add `@metamask/utils` to composable-controller deps
MajorLift Feb 7, 2024
f50716a
Define and apply `isBaseController` type guard
MajorLift Feb 8, 2024
7be4f28
test: Add test for composing a non-controller
MajorLift Feb 8, 2024
416382e
Improve BaseControllerV1 typing, type guard, comments
MajorLift Feb 8, 2024
7b9e33a
Remove unused `#controllers` class field
MajorLift Feb 8, 2024
d4a8161
Export base-controller type guards
MajorLift Feb 8, 2024
c762c6e
Remove deprecated `subscribe` property from BaseControllerV2
MajorLift Feb 8, 2024
53be427
Use `Record<string, Json>` typing for BaseControllerV2 state objects
MajorLift Feb 8, 2024
4f54841
Use generic argument for typing `reduce`
MajorLift Feb 8, 2024
f4530ab
Update CHANGELOGs
MajorLift Feb 8, 2024
724f80e
Refactor messenger typing for controller events and allowed events
MajorLift Feb 8, 2024
48669b2
Add `isValidJson` check for child state from BaseControllerV2 control…
MajorLift Feb 8, 2024
1069fa4
Update CHANGELOG
MajorLift Feb 8, 2024
d4cf789
Add `@metamask/json-rpc-engine` as devDep
MajorLift Feb 8, 2024
559b4a1
Revert "Remove deprecated `subscribe` property from BaseControllerV2"
MajorLift Feb 20, 2024
71fc9a4
Update packages/base-controller/CHANGELOG.md
MajorLift Feb 20, 2024
f7097cb
Add TODOs for removing `BaseControllerV1` related features once all c…
MajorLift Feb 21, 2024
ada78a2
Remove `ControllerList` type, add `BaseControllerV{1,2}Instance` types
MajorLift Feb 21, 2024
68f2a04
Populate metadata object in constructor with child controllers
MajorLift Feb 21, 2024
523f746
Fix jsdoc
MajorLift Feb 21, 2024
3b687f4
Add jsdoc for types `BaseControllerV{1,2}Instance`, `ControllerInstance`
MajorLift Feb 21, 2024
c5edafd
Export types `ControllerInstance`, `BaseControllerV{1,2}Instance`
MajorLift Feb 21, 2024
9e7a625
Update packages/composable-controller/CHANGELOG.md
MajorLift Feb 21, 2024
8c5acce
Update import statement for non-dependency to use `@metamask/` prefix
MajorLift Feb 21, 2024
856001b
Linter fixes
MajorLift Feb 21, 2024
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
15 changes: 15 additions & 0 deletions packages/composable-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### 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))

### Removed

- **BREAKING:** Remove `ControllerList` as an exported type. ([#3904](https://github.com/MetaMask/core/pull/3904))

## [5.0.1]

### Changed
Expand Down
4 changes: 3 additions & 1 deletion packages/composable-controller/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,12 @@
"test:watch": "jest --watch"
},
"dependencies": {
"@metamask/base-controller": "^4.1.1"
"@metamask/base-controller": "^4.1.1",
"@metamask/utils": "^8.3.0"
},
"devDependencies": {
"@metamask/auto-changelog": "^3.4.4",
"@metamask/json-rpc-engine": "^7.3.2",
"@types/jest": "^27.4.1",
"deepmerge": "^4.2.2",
"immer": "^9.0.6",
Expand Down
46 changes: 40 additions & 6 deletions packages/composable-controller/src/ComposableController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ import {
BaseControllerV1,
ControllerMessenger,
} from '@metamask/base-controller';
import { JsonRpcEngine } from '@metamask/json-rpc-engine';
import type { Patch } from 'immer';
import * as sinon from 'sinon';

import type { ComposableControllerStateChangeEvent } from './ComposableController';
import type { ComposableControllerEvents } from './ComposableController';
import { ComposableController } from './ComposableController';

// Mock BaseController classes
Expand Down Expand Up @@ -106,7 +107,10 @@ describe('ComposableController', () => {

describe('BaseControllerV1', () => {
it('should compose controller state', () => {
const composableMessenger = new ControllerMessenger().getRestricted({
const composableMessenger = new ControllerMessenger<
never,
ComposableControllerEvents
>().getRestricted({
name: 'ComposableController',
});
const controller = new ComposableController({
Expand All @@ -123,7 +127,7 @@ describe('ComposableController', () => {
it('should notify listeners of nested state change', () => {
const controllerMessenger = new ControllerMessenger<
never,
ComposableControllerStateChangeEvent
ComposableControllerEvents
>();
const composableMessenger = controllerMessenger.getRestricted({
name: 'ComposableController',
Expand Down Expand Up @@ -176,7 +180,7 @@ describe('ComposableController', () => {
it('should notify listeners of nested state change', () => {
const controllerMessenger = new ControllerMessenger<
never,
ComposableControllerStateChangeEvent | FooControllerEvent
ComposableControllerEvents | FooControllerEvent
>();
const fooControllerMessenger = controllerMessenger.getRestricted<
'FooController',
Expand Down Expand Up @@ -240,7 +244,7 @@ describe('ComposableController', () => {
const barController = new BarController();
const controllerMessenger = new ControllerMessenger<
never,
ComposableControllerStateChangeEvent | FooControllerEvent
ComposableControllerEvents | FooControllerEvent
>();
const fooControllerMessenger = controllerMessenger.getRestricted<
'FooController',
Expand Down Expand Up @@ -280,7 +284,7 @@ describe('ComposableController', () => {
const barController = new BarController();
const controllerMessenger = new ControllerMessenger<
never,
ComposableControllerStateChangeEvent | FooControllerEvent
ComposableControllerEvents | FooControllerEvent
>();
const fooControllerMessenger = controllerMessenger.getRestricted<
'FooController',
Expand Down Expand Up @@ -335,5 +339,35 @@ describe('ComposableController', () => {
}),
).toThrow('Messaging system is required');
});

it('should throw if composing a controller that does not extend from BaseController', () => {
const notController = new JsonRpcEngine();
const controllerMessenger = new ControllerMessenger<
never,
FooControllerEvent
>();
const fooControllerMessenger = controllerMessenger.getRestricted<
'FooController',
never,
never
>({
name: 'FooController',
});
const fooController = new FooController(fooControllerMessenger);
const composableControllerMessenger = controllerMessenger.getRestricted({
name: 'ComposableController',
allowedEvents: ['FooController:stateChange'],
});
expect(
() =>
new ComposableController({
// @ts-expect-error - Suppressing type error to test for runtime error handling
controllers: [notController, fooController],
messenger: composableControllerMessenger,
}),
).toThrow(
'Invalid controller: controller must extend from BaseController or BaseControllerV1',
);
});
});
});
142 changes: 105 additions & 37 deletions packages/composable-controller/src/ComposableController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Copy link
Contributor Author

@MajorLift MajorLift Feb 8, 2024

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 to 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
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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>
Copy link
Contributor Author

@MajorLift MajorLift Feb 8, 2024

Choose a reason for hiding this comment

The 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 unknown currently seems to be the best option.

To be revisited once AllowedEvents is narrowed by #3627.

>;

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,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

No reason to keep this as string since ComposableController doesn't use any actions from other controllers.

AllowedEvents['type']
>;

/**
Expand All @@ -63,8 +120,6 @@ export class ComposableController extends BaseController<
ComposableControllerState,
ComposableControllerMessenger
> {
readonly #controllers: ControllerList = [];
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Alternatively, we could keep the #controllers field and add something like the following to #updateChildController.

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.
*
Expand All @@ -77,7 +132,7 @@ export class ComposableController extends BaseController<
controllers,
messenger,
}: {
controllers: ControllerList;
controllers: ControllerInstance[];
messenger: ComposableControllerMessenger;
}) {
if (messenger === undefined) {
Expand All @@ -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;
Expand All @@ -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)) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 Record<string, Json>. It's therefore safe to narrow childState from Record<string, unknown>.

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
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Test case added for this branch.

);
}
}
Expand Down
10 changes: 8 additions & 2 deletions packages/composable-controller/src/index.ts
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';
3 changes: 3 additions & 0 deletions packages/composable-controller/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
"references": [
{
"path": "../base-controller"
},
{
"path": "../json-rpc-engine"
}
Comment on lines +10 to 12
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added json-rpc-engine as a TypeScript project reference (but not in the build tsconfig) instead of introducing it as a dependency, since it's only being used in a test file.

Copy link
Contributor

Choose a reason for hiding this comment

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

Do we also want to add json-rpc-engine as a dev dependency? Maybe it makes no difference from a TypeScript perspective, but it communicates the dependency from a Yarn/NPM perspective.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed here: 5da0893

Will a json-rpc-engine project reference need to be added to tsconfig.build.json as well?

Copy link
Contributor

Choose a reason for hiding this comment

The 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 tsconfig.build.json would be needed. You should be able to test this by running yarn build — you shouldn't see any errors.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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"]
Expand Down
2 changes: 2 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1708,6 +1708,8 @@ __metadata:
dependencies:
"@metamask/auto-changelog": ^3.4.4
"@metamask/base-controller": ^4.1.1
"@metamask/json-rpc-engine": ^7.3.2
"@metamask/utils": ^8.3.0
"@types/jest": ^27.4.1
deepmerge: ^4.2.2
immer: ^9.0.6
Expand Down
Loading