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

Conversation

MajorLift
Copy link
Contributor

@MajorLift MajorLift commented Feb 7, 2024

Explanation

There are several issues with the current ComposableController implementation that should be addressed before further updates are made to the controller (e.g. #3627).

  • Removes #controllers class field, which is not being updated by #updateChildController or anywhere else.
    • Removing this makes it clear that the list of child controllers to be composed is determined at class instantiation and cannot be altered later.
    • This behavior is consistent with #updateChildController being a private method.
  • Types BaseController, ComposableController state with Record<string, Json>
    • Opted to use any to disable BaseController state type constraint, as there is no straightforward way to type BaseControllerV1 state to be compatible with Json.
  • Adds a isBaseController type guard, and removes the deprecated subscribed property from BaseController.
  • Removes ControllerList type in anticipation of [composable-controller] Make class and messenger generic upon child controllers #3627. Internally, ControllerInstance will be used to type child controllers or unions and tuples thereof.
  • Adds BaseControllerV{1,2}Instance types.
  • Populates state metadata object in constructor with child controllers.

References

Changelog

Recorded under "Unreleased" heading in CHANGELOG files.

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate
  • I've highlighted breaking changes using the "BREAKING" category above as appropriate

@MajorLift MajorLift self-assigned this Feb 7, 2024
@MajorLift MajorLift force-pushed the 230207-fix-composable-controller branch from a00ed44 to 3d8f0f6 Compare February 7, 2024 20:59
@MajorLift MajorLift changed the title [composable-controller] Various fixes [composable-controller] Fix incorrect #updateChildController behavior and improve typing Feb 7, 2024
@MajorLift MajorLift force-pushed the 230207-fix-composable-controller branch 9 times, most recently from 6c5b5de to 818075d Compare February 8, 2024 02:03
@MajorLift MajorLift marked this pull request as ready for review February 8, 2024 02:05
@MajorLift MajorLift requested a review from a team as a code owner February 8, 2024 02:05
@MajorLift MajorLift changed the title [composable-controller] Fix incorrect #updateChildController behavior and improve typing [composable-controller] Remove unused #controllers field, better typing for state, strict type checks for inputs Feb 8, 2024
Comment on lines +10 to 12
{
"path": "../json-rpc-engine"
}
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.

@MajorLift MajorLift force-pushed the 230207-fix-composable-controller branch 3 times, most recently from b4fe231 to 3b50a63 Compare February 8, 2024 04:11
Comment on lines 71 to 90
// `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`.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[name: string]: Record<string, any>;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Previous typing of ControllerInstance['state'] implicitly evaluated as any.

This is the error if any is replaced with something wider than Json e.g. unknown:

Type 'ComposableControllerState' does not satisfy the constraint 'Record<string, Json>'.
  'string' index signatures are incompatible.
    Type 'Record<string, unknown>' is not assignable to type 'Json'.

};

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.

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.

@MajorLift MajorLift force-pushed the 230207-fix-composable-controller branch from 529eb3b to 2e551e2 Compare February 8, 2024 04:22
Comment on lines +167 to +192
throw new Error(
'Invalid controller: controller must extend from BaseController or BaseControllerV1',
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.

(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>.

Comment on lines +36 to +82
): 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
);
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.

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>.

@MajorLift MajorLift changed the title [composable-controller] Remove unused #controllers field, better typing for state, strict type checks for inputs [composable-controller] Better typing for state, messenger, strict type checks for inputs, remove #controllers field Feb 8, 2024
@@ -63,8 +104,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.

MajorLift and others added 24 commits February 21, 2024 15:18
Co-authored-by: Elliot Winkler <elliot.winkler@gmail.com>
Co-authored-by: Elliot Winkler <elliot.winkler@gmail.com>
Linter fix
@MajorLift MajorLift force-pushed the 230207-fix-composable-controller branch from bd8905a to 856001b Compare February 21, 2024 20:19
Copy link
Contributor

@mcmire mcmire left a comment

Choose a reason for hiding this comment

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

Looks good!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
2 participants