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] Enable handling of non-controller input #4943

1 change: 0 additions & 1 deletion packages/base-controller/src/BaseControllerV1.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ describe('isBaseControllerV1', () => {

it('should return false if passed a non-controller', () => {
const notController = new JsonRpcEngine();
// @ts-expect-error Intentionally passing invalid input to test runtime behavior
expect(isBaseControllerV1(notController)).toBe(false);
});
});
Expand Down
8 changes: 4 additions & 4 deletions packages/base-controller/src/BaseControllerV1.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import type { PublicInterface } from '@metamask/utils';

import type { ControllerInstance } from './BaseControllerV2';
import { isNullOrUndefined, type PublicInterface } from '@metamask/utils';

/**
* Determines if the given controller is an instance of `BaseControllerV1`
Expand All @@ -9,9 +7,11 @@ import type { ControllerInstance } from './BaseControllerV2';
* @returns True if the controller is an instance of `BaseControllerV1`
*/
export function isBaseControllerV1(
controller: ControllerInstance,
controller: unknown,
): controller is BaseControllerV1Instance {
return (
typeof controller === 'object' &&
!isNullOrUndefined(controller) &&
'name' in controller &&
typeof controller.name === 'string' &&
'config' in controller &&
Expand Down
1 change: 0 additions & 1 deletion packages/base-controller/src/BaseControllerV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,6 @@ describe('isBaseController', () => {

it('should return false if passed a non-controller', () => {
const notController = new JsonRpcEngine();
// @ts-expect-error Intentionally passing invalid input to test runtime behavior
expect(isBaseController(notController)).toBe(false);
});
});
Expand Down
14 changes: 10 additions & 4 deletions packages/base-controller/src/BaseControllerV2.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type { Json, PublicInterface } from '@metamask/utils';
import {
isNullOrUndefined,
type Json,
type PublicInterface,
} from '@metamask/utils';
import { enablePatches, produceWithPatches, applyPatches, freeze } from 'immer';
import type { Draft, Patch } from 'immer';

Expand All @@ -21,9 +25,11 @@ enablePatches();
* @returns True if the controller is an instance of `BaseController`
*/
export function isBaseController(
controller: ControllerInstance,
controller: unknown,
): controller is BaseControllerInstance {
return (
typeof controller === 'object' &&
!isNullOrUndefined(controller) &&
'name' in controller &&
typeof controller.name === 'string' &&
'state' in controller &&
Expand Down Expand Up @@ -130,7 +136,7 @@ export type StateMetadataConstraint = Record<
>;

/**
* The widest subtype of all controller instances that inherit from `BaseController` (formerly `BaseControllerV2`).
* The narrowest supertype of all controller instances that inherit from `BaseController` (formerly `BaseControllerV2`).
* Any `BaseController` subclass instance can be assigned to this type.
*/
export type BaseControllerInstance = Omit<
Expand All @@ -147,7 +153,7 @@ export type BaseControllerInstance = Omit<
};

/**
* A widest subtype of all controller instances that inherit from `BaseController` (formerly `BaseControllerV2`) or `BaseControllerV1`.
* A narrowest supertype of all controller instances that inherit from `BaseController` (formerly `BaseControllerV2`) or `BaseControllerV1`.
* Any `BaseController` or `BaseControllerV1` subclass instance can be assigned to this type.
*/
// TODO: Remove once BaseControllerV2 migrations are completed for all controllers.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,7 @@ import type {
ChildControllerStateChangeEvents,
ComposableControllerEvents,
} from './ComposableController';
import {
ComposableController,
INVALID_CONTROLLER_ERROR,
} from './ComposableController';
import { ComposableController } from './ComposableController';

// Mock BaseController classes

Expand Down Expand Up @@ -531,7 +528,7 @@ describe('ComposableController', () => {
).toThrow('Messaging system is required');
});

it('should throw if composing a controller that does not extend from BaseController', () => {
it('should not throw if composing a controller that does not extend from BaseController', () => {
type ComposableControllerState = {
// TODO: Either fix this lint violation or explain why it's necessary to ignore.
// eslint-disable-next-line @typescript-eslint/naming-convention
Expand Down Expand Up @@ -564,7 +561,7 @@ describe('ComposableController', () => {
controllers: [notController, fooController],
messenger: composableControllerMessenger,
}),
).toThrow(INVALID_CONTROLLER_ERROR);
).not.toThrow();
});
});
});
89 changes: 58 additions & 31 deletions packages/composable-controller/src/ComposableController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,32 @@ import type { Patch } from 'immer';

export const controllerName = 'ComposableController';

export const INVALID_CONTROLLER_ERROR =
'Invalid controller: controller must have a `messagingSystem` or be a class inheriting from `BaseControllerV1`.';
// TODO: Replace with type guard once superclass for messageable non-controllers has been defined.
Copy link
Contributor

Choose a reason for hiding this comment

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

If the goal is to figure out which controllers do not have state, can we check for the absence of the state property?

Copy link
Contributor Author

@MajorLift MajorLift Nov 19, 2024

Choose a reason for hiding this comment

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

Unfortunately only AssetsContractController lacks a state property.

NftDetectionController and TokenDetectionController both have a state property which is assigned an empty object of type Record<never, never>, because both inherit from BaseController.

Checking for an empty state object is not useful because there could be controllers with non-empty state that are initialized with empty state objects.

These inconsistencies make the changes in this PR less type-safe than they could be. This ties in to one of the motivations for the MessengerClient/Messageable ADR. which is to align inheritance for stateless non-controllers.

Copy link
Contributor

Choose a reason for hiding this comment

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

Oh, I see. My thought is that we could clean this up by assuming that the non-controllers do not inherit from BaseController. I forgot that we will do that through the MessengerClient/Messageable ADR though.

Copy link
Contributor Author

@MajorLift MajorLift Nov 19, 2024

Choose a reason for hiding this comment

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

There's currently no common supertype for the stateless non-controllers that is narrower than an object with a 'name' property.

Ideally, we would be able to exclude all classes with properties 'name', 'messagingSystem' and without 'state', 'metadata', but since that's currently not an option until the ADR is implemented, I went for just explicitly specifying the non-controllers, so we wouldn't have to deal with edge cases in the input.

I refactored the logic a bit. Hopefully this makes the intention a bit clearer: 85389bb

export const STATELESS_NONCONTROLLER_NAMES = [
'AssetsContractController',
'NftDetectionController',
'TokenDetectionController',
] as const;

/**
* A type guard for the {@link ControllerInstance} type that specifically filters out non-controllers that have empty state.
* @param controller - The controller instance to check.
* @returns A type predicate that evaluates to true if the input is a {@link ControllerInstance} type.
*/
export function isController(
controller: unknown,
): controller is ControllerInstance {
return (
(isBaseController(controller) || isBaseControllerV1(controller)) &&
!STATELESS_NONCONTROLLER_NAMES.find((name) => name === controller.name)
);
}

/**
* A universal supertype for modules with a 'string'-type `name` property.
* This type is intended to encompass controller and non-controller input that can be passed into the {@link ComposableController} `controllers` constructor option.
*/
export type NamedModule = { name: string };

/**
* A universal supertype for the composable controller state object.
Expand All @@ -30,7 +54,7 @@ type LegacyComposableControllerStateConstraint = {
// `any` is used here to disable the generic constraint on the `ControllerState` type argument in the `BaseController` type,
// enabling composable controller state types with BaseControllerV1 state objects to be.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[name: string]: Record<string, any>;
[controllerName: string]: Record<string, any>;
};

/**
Expand All @@ -39,7 +63,7 @@ type LegacyComposableControllerStateConstraint = {
*/
// TODO: Replace with `{ [name: string]: StateConstraint }` once BaseControllerV2 migrations are completed for all controllers.
export type ComposableControllerStateConstraint = {
[name: string]: LegacyControllerStateConstraint;
[controllerName: string]: LegacyControllerStateConstraint;
};

/**
Expand Down Expand Up @@ -125,12 +149,12 @@ export type ComposableControllerMessenger<
/**
* Controller that composes multiple child controllers and maintains up-to-date composed state.
*
* @template ComposableControllerState - A type object containing the names and state types of the child controllers.
* @template ChildControllers - A union type of the child controllers being used to instantiate the {@link ComposableController}.
* @template ComposableControllerState - A type object containing the names and state types of the child controllers. Any non-controllers with empty state should be omitted from this type argument.
* @template ChildControllers - A union type of the child controllers being used to instantiate the {@link ComposableController}. Non-controllers that are passed into the `controllers` constructor option at runtime should be included in this type argument.
*/
export class ComposableController<
ComposableControllerState extends LegacyComposableControllerStateConstraint,
ChildControllers extends ControllerInstance,
ChildControllers extends NamedModule,
> extends BaseController<
typeof controllerName,
ComposableControllerState,
Expand All @@ -140,7 +164,7 @@ export class ComposableController<
* Creates a ComposableController instance.
*
* @param options - Initial options used to configure this controller
* @param options.controllers - List of child controller instances to compose.
* @param options.controllers - List of child controller instances to compose. Any non-controllers that are included here will be excluded from the composed state object and from `stateChange` event subscription.
* @param options.messenger - A restricted controller messenger.
*/

Expand All @@ -158,18 +182,27 @@ export class ComposableController<
super({
name: controllerName,
metadata: controllers.reduce<StateMetadata<ComposableControllerState>>(
(metadata, controller) => ({
...metadata,
[controller.name]: isBaseController(controller)
? controller.metadata
: { persist: true, anonymous: true },
}),
(metadata, controller) => {
if (!isController(controller)) {
return metadata;
}
return Object.assign(
metadata,
isBaseController(controller)
? { [controller.name]: controller.metadata }
: { [controller.name]: { persist: true, anonymous: true } },
);
},
{} as never,
),
state: controllers.reduce<ComposableControllerState>(
(state, controller) => {
return { ...state, [controller.name]: controller.state };
},
(state, controller) =>
Object.assign(
state,
isController(controller)
? { [controller.name]: controller.state }
: {},
),
{} as never,
),
messenger,
Expand All @@ -185,31 +218,25 @@ export class ComposableController<
*
* @param controller - Controller instance to update
*/
#updateChildController(controller: ControllerInstance): void {
const { name } = controller;
if (!isBaseController(controller) && !isBaseControllerV1(controller)) {
// False negative. `name` is a string type.
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
throw new Error(`${name} - ${INVALID_CONTROLLER_ERROR}`);
#updateChildController(controller: ChildControllers): void {
if (!isController(controller)) {
return;
}
const { name } = controller;
try {
this.messagingSystem.subscribe(
// False negative. `name` is a string type.
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
`${name}:stateChange`,
(childState: LegacyControllerStateConstraint) => {
this.update((state) => {
Object.assign(state, { [name]: childState });
});
},
);
} catch (error: unknown) {
// False negative. `name` is a string type.
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
console.error(`${name} - ${String(error)}`);
}
// Invalid/non-existent event names from V1 controllers and non-controllers are expected, and should be handled without blocking ComposableController instantiation in downstream clients.
// eslint-disable-next-line no-empty
} catch (error: unknown) {}
if (isBaseControllerV1(controller)) {
controller.subscribe((childState: StateConstraintV1) => {
controller.subscribe((childState) => {
this.update((state) => {
Object.assign(state, { [name]: childState });
});
Expand Down
5 changes: 4 additions & 1 deletion packages/composable-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,7 @@ export type {
ComposableControllerEvents,
ComposableControllerMessenger,
} from './ComposableController';
export { ComposableController } from './ComposableController';
export {
ComposableController,
STATELESS_NONCONTROLLER_NAMES,
} from './ComposableController';
Loading