Skip to content

Commit

Permalink
Add controller messaging system
Browse files Browse the repository at this point in the history
Adds a controller messaging system, which will be used to facilitate
inter-controller communication. The controller messenger acts as a
message broker, passing actions and events back and forth between
controllers. It is _fully type safe_.

This idea was described in the Controller Messaging System proposal[1].

This controller messenger doesn't set support attenuation, so it's not
yet possible to restrict which actions and events are interacted with.
That will be coming in a later PR.

[1]: https://www.notion.so/Controller-Messaging-System-617efb02b9e54bd0a0b0e44c6f776d85
  • Loading branch information
Gudahtt committed Mar 8, 2021
1 parent b3ae708 commit 756e986
Show file tree
Hide file tree
Showing 4 changed files with 493 additions and 29 deletions.
118 changes: 98 additions & 20 deletions src/BaseControllerV2.test.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,27 @@
import type { Draft } from 'immer';
import type { Draft, Patch } from 'immer';
import * as sinon from 'sinon';

import { BaseController, getAnonymizedState, getPersistentState } from './BaseControllerV2';
import { ControllerMessenger } from './ControllerMessenger';

type MockControllerState = {
type CountControllerState = {
count: number;
};

const mockControllerStateMetadata = {
type CountControllerEvent = {
type: `CountController:state-change`;
payload: [CountControllerState, Patch[]];
};

const CountControllerStateMetadata = {
count: {
persist: true,
anonymous: true,
},
};

class MockController extends BaseController<MockControllerState> {
update(callback: (state: Draft<MockControllerState>) => void | MockControllerState) {
class MockController extends BaseController<'CountController', CountControllerState> {
update(callback: (state: Draft<CountControllerState>) => void | CountControllerState) {
super.update(callback);
}

Expand All @@ -26,27 +32,51 @@ class MockController extends BaseController<MockControllerState> {

describe('BaseController', () => {
it('should set initial state', () => {
const controller = new MockController({ count: 0 }, mockControllerStateMetadata);
const controllerMessenger = new ControllerMessenger<never, CountControllerEvent>();
const controller = new MockController(
controllerMessenger,
'CountController',
{ count: 0 },
CountControllerStateMetadata,
);

expect(controller.state).toEqual({ count: 0 });
});

it('should set initial schema', () => {
const controller = new MockController({ count: 0 }, mockControllerStateMetadata);
const controllerMessenger = new ControllerMessenger<never, CountControllerEvent>();
const controller = new MockController(
controllerMessenger,
'CountController',
{ count: 0 },
CountControllerStateMetadata,
);

expect(controller.metadata).toEqual(mockControllerStateMetadata);
expect(controller.metadata).toEqual(CountControllerStateMetadata);
});

it('should not allow mutating state directly', () => {
const controller = new MockController({ count: 0 }, mockControllerStateMetadata);
const controllerMessenger = new ControllerMessenger<never, CountControllerEvent>();
const controller = new MockController(
controllerMessenger,
'CountController',
{ count: 0 },
CountControllerStateMetadata,
);

expect(() => {
controller.state = { count: 1 };
}).toThrow();
});

it('should allow updating state by modifying draft', () => {
const controller = new MockController({ count: 0 }, mockControllerStateMetadata);
const controllerMessenger = new ControllerMessenger<never, CountControllerEvent>();
const controller = new MockController(
controllerMessenger,
'CountController',
{ count: 0 },
CountControllerStateMetadata,
);

controller.update((draft) => {
draft.count += 1;
Expand All @@ -56,7 +86,13 @@ describe('BaseController', () => {
});

it('should allow updating state by return a value', () => {
const controller = new MockController({ count: 0 }, mockControllerStateMetadata);
const controllerMessenger = new ControllerMessenger<never, CountControllerEvent>();
const controller = new MockController(
controllerMessenger,
'CountController',
{ count: 0 },
CountControllerStateMetadata,
);

controller.update(() => {
return { count: 1 };
Expand All @@ -66,7 +102,13 @@ describe('BaseController', () => {
});

it('should throw an error if update callback modifies draft and returns value', () => {
const controller = new MockController({ count: 0 }, mockControllerStateMetadata);
const controllerMessenger = new ControllerMessenger<never, CountControllerEvent>();
const controller = new MockController(
controllerMessenger,
'CountController',
{ count: 0 },
CountControllerStateMetadata,
);

expect(() => {
controller.update((draft) => {
Expand All @@ -77,7 +119,13 @@ describe('BaseController', () => {
});

it('should inform subscribers of state changes', () => {
const controller = new MockController({ count: 0 }, mockControllerStateMetadata);
const controllerMessenger = new ControllerMessenger<never, CountControllerEvent>();
const controller = new MockController(
controllerMessenger,
'CountController',
{ count: 0 },
CountControllerStateMetadata,
);
const listener1 = sinon.stub();
const listener2 = sinon.stub();

Expand All @@ -94,7 +142,13 @@ describe('BaseController', () => {
});

it('should inform a subscriber of each state change once even after multiple subscriptions', () => {
const controller = new MockController({ count: 0 }, mockControllerStateMetadata);
const controllerMessenger = new ControllerMessenger<never, CountControllerEvent>();
const controller = new MockController(
controllerMessenger,
'CountController',
{ count: 0 },
CountControllerStateMetadata,
);
const listener1 = sinon.stub();

controller.subscribe(listener1);
Expand All @@ -108,7 +162,13 @@ describe('BaseController', () => {
});

it('should no longer inform a subscriber about state changes after unsubscribing', () => {
const controller = new MockController({ count: 0 }, mockControllerStateMetadata);
const controllerMessenger = new ControllerMessenger<never, CountControllerEvent>();
const controller = new MockController(
controllerMessenger,
'CountController',
{ count: 0 },
CountControllerStateMetadata,
);
const listener1 = sinon.stub();

controller.subscribe(listener1);
Expand All @@ -121,7 +181,13 @@ describe('BaseController', () => {
});

it('should no longer inform a subscriber about state changes after unsubscribing once, even if they subscribed many times', () => {
const controller = new MockController({ count: 0 }, mockControllerStateMetadata);
const controllerMessenger = new ControllerMessenger<never, CountControllerEvent>();
const controller = new MockController(
controllerMessenger,
'CountController',
{ count: 0 },
CountControllerStateMetadata,
);
const listener1 = sinon.stub();

controller.subscribe(listener1);
Expand All @@ -134,17 +200,29 @@ describe('BaseController', () => {
expect(listener1.callCount).toEqual(0);
});

it('should allow unsubscribing listeners who were never subscribed', () => {
const controller = new MockController({ count: 0 }, mockControllerStateMetadata);
it('should throw when unsubscribing listener who was never subscribed', () => {
const controllerMessenger = new ControllerMessenger<never, CountControllerEvent>();
const controller = new MockController(
controllerMessenger,
'CountController',
{ count: 0 },
CountControllerStateMetadata,
);
const listener1 = sinon.stub();

expect(() => {
controller.unsubscribe(listener1);
}).not.toThrow();
}).toThrow();
});

it('should no longer update subscribers after being destroyed', () => {
const controller = new MockController({ count: 0 }, mockControllerStateMetadata);
const controllerMessenger = new ControllerMessenger<never, CountControllerEvent>();
const controller = new MockController(
controllerMessenger,
'CountController',
{ count: 0 },
CountControllerStateMetadata,
);
const listener1 = sinon.stub();
const listener2 = sinon.stub();

Expand Down
28 changes: 19 additions & 9 deletions src/BaseControllerV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { enablePatches, produceWithPatches } from 'immer';
// eslint-disable-next-line no-duplicate-imports
import type { Draft, Patch } from 'immer';

import type { ControllerMessenger } from './ControllerMessenger';

enablePatches();

/**
Expand Down Expand Up @@ -96,21 +98,31 @@ type Json = null | boolean | number | string | Json[] | { [prop: string]: Json }
/**
* Controller class that provides state management, subscriptions, and state metadata
*/
export class BaseController<S extends Record<string, unknown>> {
export class BaseController<N extends string, S extends Record<string, unknown>> {
private internalState: IsJsonable<S>;

private internalListeners: Set<Listener<S>> = new Set();
private messagingSystem: ControllerMessenger<never, { type: `${N}:state-change`; payload: [S, Patch[]] }>;

private name: N;

public readonly metadata: StateMetadata<S>;

/**
* Creates a BaseController instance.
*
* @param messagingSystem - Controller messaging system
* @param state - Initial controller state
* @param metadata - State metadata, describing how to "anonymize" the state,
* and which parts should be persisted.
*/
constructor(state: IsJsonable<S>, metadata: StateMetadata<S>) {
constructor(
messagingSystem: ControllerMessenger<never, { type: `${N}:state-change`; payload: [S, Patch[]] }>,
name: N,
state: IsJsonable<S>,
metadata: StateMetadata<S>,
) {
this.messagingSystem = messagingSystem;
this.name = name;
this.internalState = state;
this.metadata = metadata;
}
Expand All @@ -134,7 +146,7 @@ export class BaseController<S extends Record<string, unknown>> {
* @param listener - Callback triggered when state changes
*/
subscribe(listener: Listener<S>) {
this.internalListeners.add(listener);
this.messagingSystem.subscribe(`${this.name}:state-change` as `${N}:state-change`, listener);
}

/**
Expand All @@ -143,7 +155,7 @@ export class BaseController<S extends Record<string, unknown>> {
* @param listener - Callback to remove
*/
unsubscribe(listener: Listener<S>) {
this.internalListeners.delete(listener);
this.messagingSystem.unsubscribe(`${this.name}:state-change` as `${N}:state-change`, listener);
}

/**
Expand All @@ -158,9 +170,7 @@ export class BaseController<S extends Record<string, unknown>> {
protected update(callback: (state: Draft<IsJsonable<S>>) => void | IsJsonable<S>) {
const [nextState, patches] = produceWithPatches(this.internalState, callback);
this.internalState = nextState as IsJsonable<S>;
for (const listener of this.internalListeners) {
listener(nextState as S, patches);
}
this.messagingSystem.publish(`${this.name}:state-change` as `${N}:state-change`, nextState as S, patches);
}

/**
Expand All @@ -173,7 +183,7 @@ export class BaseController<S extends Record<string, unknown>> {
* listeners from being garbage collected.
*/
protected destroy() {
this.internalListeners.clear();
this.messagingSystem.clearEventSubscriptions(`${this.name}:state-change` as `${N}:state-change`);
}
}

Expand Down
Loading

0 comments on commit 756e986

Please sign in to comment.