Skip to content

Commit c522a4d

Browse files
authored
[composable-controller] Fix incorrect behavior and improve type-level safeguards (#4467)
## Overview This commit fixes issues with the `ComposableController` class's interface, and its logic for validating V1 and V2 controllers. These changes will enable `ComposableController` to function correctly downstream in the Mobile Engine, and eventually the Wallet Framework POC. ## Explanation The previous approach of generating mock controller classes from the `ComposableControllerState` input using the `GetChildControllers` was flawed, because the mock controllers would always be incomplete, complicating attempts at validation. Instead, we now rely on the downstream consumer to provide both a composed type of state schemas (`ComposableControllerState`) and a type union of the child controller instances (`ChildControllers`). For example, in mobile, we can use (with some adjustments) `EngineState` for the former, and `Controllers[keyof Controllers]` for the latter. The validation logic for V1 controllers has also been updated. Due to breaking changes made to the private properties of `BaseControllerV1` (#3959), mobile V1 controllers relying on versions prior to these changes were introduced were incompatible with the up-to-date `BaseControllerV1` version that the composable-controller package references. In this commit, the validator type `BaseControllerV1Instance` filters out the problematic private properties by using the `PublicInterface` type. Because the public API of `BaseControllerV1` has been relatively constant, this removes the type errors that previously occurred in mobile when passing V1 controllers into `ComposableController`. ## References - Closes #4448 - Contributes to #4213 - Next steps MetaMask/metamask-mobile#10073 - See MetaMask/metamask-mobile#10011 ## Changelog ### `@metamask/composable-controller` (major) ### Changed - **BREAKING:** Add two required generic parameters to the `ComposableController` class: `ComposedControllerState` (constrained by `LegacyComposableControllerStateConstraint`) and `ChildControllers` (constrained by `ControllerInstance`) ([#4467](#4467)). - **BREAKING:** The type guard `isBaseController` now validates that the input has an object-type property named `metadata` in addition to its existing checks. - **BREAKING:** The type guard `isBaseControllerV1` now validates that the input has object-type properties `config`, `state`, and function-type property `subscribe`, in addition to its existing checks. - **BREAKING:** Narrow `LegacyControllerStateConstraint` type from `BaseState | StateConstraint` to `BaseState & object | StateConstraint`. - Add an optional generic parameter `ControllerName` to the `RestrictedControllerMessengerConstraint` type, which extends `string` and defaults to `string`. ### Fixed - **BREAKING:** The `ComposableController` class raises a type error if a non-controller with no `state` property is passed into the `ChildControllers` generic parameter or the `controllers` constructor option. - Previously, a runtime error was thrown at class instantiation with no type-level enforcement. - When the `ComposableController` class is instantiated, its messenger now attempts to subscribe to all child controller `stateChange` events that are included in the messenger's events allowlist. - This was always the expected behavior, but a bug introduced in `@metamask/composable-controller@6.0.0` caused `stateChange` event subscriptions to fail. - `isBaseController` and `isBaseControllerV1` no longer return false negatives. - The `instanceof` operator is no longer used to validate that the input is a subclass of `BaseController` or `BaseControllerV1`. - The `ChildControllerStateChangeEvents` type checks that the child controller's state extends from the `StateConstraintV1` type instead of from `Record<string, unknown>`. ([#4467](#4467)) - V1 controllers define their state types using the `interface` keyword, which are incompatible with `Record<string, unknown>` by default. This resulted in `ChildControllerStateChangeEvents` failing to generate `stateChange` events for V1 controllers and returning `never`. ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've highlighted breaking changes using the "BREAKING" category above as appropriate
1 parent 19e29c4 commit c522a4d

File tree

4 files changed

+220
-88
lines changed

4 files changed

+220
-88
lines changed

packages/composable-controller/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"devDependencies": {
4747
"@metamask/auto-changelog": "^3.4.4",
4848
"@metamask/json-rpc-engine": "^9.0.2",
49+
"@metamask/utils": "^9.1.0",
4950
"@types/jest": "^27.4.1",
5051
"deepmerge": "^4.2.2",
5152
"immer": "^9.0.6",

packages/composable-controller/src/ComposableController.test.ts

Lines changed: 92 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,14 @@ import { JsonRpcEngine } from '@metamask/json-rpc-engine';
1111
import type { Patch } from 'immer';
1212
import * as sinon from 'sinon';
1313

14-
import type { ComposableControllerEvents } from './ComposableController';
15-
import { ComposableController } from './ComposableController';
14+
import type {
15+
ChildControllerStateChangeEvents,
16+
ComposableControllerEvents,
17+
} from './ComposableController';
18+
import {
19+
ComposableController,
20+
INVALID_CONTROLLER_ERROR,
21+
} from './ComposableController';
1622

1723
// Mock BaseController classes
1824

@@ -130,6 +136,18 @@ class BarController extends BaseControllerV1<never, BarControllerState> {
130136
type BazControllerState = BaseState & {
131137
baz: string;
132138
};
139+
type BazControllerEvent = {
140+
type: `BazController:stateChange`;
141+
payload: [BazControllerState, Patch[]];
142+
};
143+
144+
type BazMessenger = RestrictedControllerMessenger<
145+
'BazController',
146+
never,
147+
BazControllerEvent,
148+
never,
149+
never
150+
>;
133151

134152
class BazController extends BaseControllerV1<never, BazControllerState> {
135153
defaultState = {
@@ -138,12 +156,30 @@ class BazController extends BaseControllerV1<never, BazControllerState> {
138156

139157
override name = 'BazController' as const;
140158

141-
constructor() {
159+
protected messagingSystem: BazMessenger;
160+
161+
constructor({ messenger }: { messenger: BazMessenger }) {
142162
super();
143163
this.initialize();
164+
this.messagingSystem = messenger;
144165
}
145166
}
146167

168+
type ControllersMap = {
169+
// TODO: Either fix this lint violation or explain why it's necessary to ignore.
170+
// eslint-disable-next-line @typescript-eslint/naming-convention
171+
FooController: FooController;
172+
// TODO: Either fix this lint violation or explain why it's necessary to ignore.
173+
// eslint-disable-next-line @typescript-eslint/naming-convention
174+
QuzController: QuzController;
175+
// TODO: Either fix this lint violation or explain why it's necessary to ignore.
176+
// eslint-disable-next-line @typescript-eslint/naming-convention
177+
BarController: BarController;
178+
// TODO: Either fix this lint violation or explain why it's necessary to ignore.
179+
// eslint-disable-next-line @typescript-eslint/naming-convention
180+
BazController: BazController;
181+
};
182+
147183
describe('ComposableController', () => {
148184
afterEach(() => {
149185
sinon.restore();
@@ -159,16 +195,30 @@ describe('ComposableController', () => {
159195
// eslint-disable-next-line @typescript-eslint/naming-convention
160196
BazController: BazControllerState;
161197
};
198+
162199
const composableMessenger = new ControllerMessenger<
163200
never,
164-
ComposableControllerEvents<ComposableControllerState>
201+
| ComposableControllerEvents<ComposableControllerState>
202+
| ChildControllerStateChangeEvents<ComposableControllerState>
165203
>().getRestricted({
166204
name: 'ComposableController',
167205
allowedActions: [],
168-
allowedEvents: [],
206+
allowedEvents: ['BazController:stateChange'],
169207
});
170-
const controller = new ComposableController({
171-
controllers: [new BarController(), new BazController()],
208+
const controller = new ComposableController<
209+
ComposableControllerState,
210+
ControllersMap[keyof ComposableControllerState]
211+
>({
212+
controllers: [
213+
new BarController(),
214+
new BazController({
215+
messenger: new ControllerMessenger<never, never>().getRestricted({
216+
name: 'BazController',
217+
allowedActions: [],
218+
allowedEvents: [],
219+
}),
220+
}),
221+
],
172222
messenger: composableMessenger,
173223
});
174224

@@ -194,7 +244,10 @@ describe('ComposableController', () => {
194244
allowedEvents: [],
195245
});
196246
const barController = new BarController();
197-
new ComposableController({
247+
new ComposableController<
248+
ComposableControllerState,
249+
ControllersMap[keyof ComposableControllerState]
250+
>({
198251
controllers: [barController],
199252
messenger: composableMessenger,
200253
});
@@ -255,11 +308,13 @@ describe('ComposableController', () => {
255308
'QuzController:stateChange',
256309
],
257310
});
258-
const composableController =
259-
new ComposableController<ComposableControllerState>({
260-
controllers: [fooController, quzController],
261-
messenger: composableControllerMessenger,
262-
});
311+
const composableController = new ComposableController<
312+
ComposableControllerState,
313+
ControllersMap[keyof ComposableControllerState]
314+
>({
315+
controllers: [fooController, quzController],
316+
messenger: composableControllerMessenger,
317+
});
263318
expect(composableController.state).toStrictEqual({
264319
FooController: { foo: 'foo' },
265320
QuzController: { quz: 'quz' },
@@ -288,7 +343,10 @@ describe('ComposableController', () => {
288343
allowedActions: [],
289344
allowedEvents: ['FooController:stateChange'],
290345
});
291-
new ComposableController<ComposableControllerState>({
346+
new ComposableController<
347+
ComposableControllerState,
348+
ControllersMap[keyof ComposableControllerState]
349+
>({
292350
controllers: [fooController],
293351
messenger: composableControllerMessenger,
294352
});
@@ -336,11 +394,13 @@ describe('ComposableController', () => {
336394
allowedActions: [],
337395
allowedEvents: ['FooController:stateChange'],
338396
});
339-
const composableController =
340-
new ComposableController<ComposableControllerState>({
341-
controllers: [barController, fooController],
342-
messenger: composableControllerMessenger,
343-
});
397+
const composableController = new ComposableController<
398+
ComposableControllerState,
399+
ControllersMap[keyof ComposableControllerState]
400+
>({
401+
controllers: [barController, fooController],
402+
messenger: composableControllerMessenger,
403+
});
344404
expect(composableController.state).toStrictEqual({
345405
BarController: { bar: 'bar' },
346406
FooController: { foo: 'foo' },
@@ -373,7 +433,10 @@ describe('ComposableController', () => {
373433
allowedActions: [],
374434
allowedEvents: ['FooController:stateChange'],
375435
});
376-
new ComposableController<ComposableControllerState>({
436+
new ComposableController<
437+
ComposableControllerState,
438+
ControllersMap[keyof ComposableControllerState]
439+
>({
377440
controllers: [barController, fooController],
378441
messenger: composableControllerMessenger,
379442
});
@@ -421,7 +484,10 @@ describe('ComposableController', () => {
421484
allowedActions: [],
422485
allowedEvents: ['FooController:stateChange'],
423486
});
424-
new ComposableController<ComposableControllerState>({
487+
new ComposableController<
488+
ComposableControllerState,
489+
ControllersMap[keyof ComposableControllerState]
490+
>({
425491
controllers: [barController, fooController],
426492
messenger: composableControllerMessenger,
427493
});
@@ -490,14 +556,15 @@ describe('ComposableController', () => {
490556
});
491557
expect(
492558
() =>
493-
new ComposableController({
559+
new ComposableController<
560+
ComposableControllerState,
561+
ControllersMap[keyof ComposableControllerState]
562+
>({
494563
// @ts-expect-error - Suppressing type error to test for runtime error handling
495564
controllers: [notController, fooController],
496565
messenger: composableControllerMessenger,
497566
}),
498-
).toThrow(
499-
'Invalid controller: controller must extend from BaseController or BaseControllerV1',
500-
);
567+
).toThrow(INVALID_CONTROLLER_ERROR);
501568
});
502569
});
503570
});

0 commit comments

Comments
 (0)