Skip to content

Commit

Permalink
Feature: Add selector subscriptions (#551)
Browse files Browse the repository at this point in the history
This non-breaking PR adds [`reselect`](https://npmjs.com/package/reselect)-style selectors as an optional parameter to controller messenger event subscriptions. We call subscriptions with selectors **selector subscriptions**. This enables subscribing to subsets of large event payloads, for example `*:stateChange` events.

```typescript
// ControllerMessenger.subscribe selector signature
subscribe<E extends Event['type'], V>(
  eventType: E,
  handler: (nextValue: V, previousValue: V | undefined) => void,
  selector: (payload: ExtractEventPayload<Event, E>) => V,
): void;
```

When a selector subscription is triggered, the corresponding selector is passed the entire event payload, and returns a single value. If the value has changed since the last occurrence of the event, the handler is called with the new value and the previous value, or `undefined` if no previous value exists. Since controller state is immutable, the event handler can efficiently diff the selector return values and trigger other logic dependent on changes to subsets of complex event payloads (e.g., other events).

This feature is motivated by our frequent need to subscribe to controller substate(s). Every controller has a generic `*:stateChange` event, but parsing that state is often error-prone and/or annoying. By adding selector subscriptions, we can performantly parse complex controller state in one place, and emit more specific events as necessary. This implementation was chosen due to our existing widespread use of selectors in our UIs, and in order to avoid the introduction of querying DSLs such as JSONPath and GraphQL at this time.

In addition to adding selector subscriptions, this PR modifies an error messages and does some minor, non-breaking type touchup.

Closes #528
  • Loading branch information
rekmarks committed Aug 5, 2021
1 parent d2d91b6 commit a143120
Show file tree
Hide file tree
Showing 3 changed files with 275 additions and 20 deletions.
4 changes: 1 addition & 3 deletions src/BaseControllerV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,9 +297,7 @@ describe('BaseController', () => {

expect(() => {
controllerMessenger.unsubscribe('CountController:stateChange', listener1);
}).toThrow(
"Subscription not found for event: 'CountController:stateChange'",
);
}).toThrow('Subscription not found for event: CountController:stateChange');
});

it('should no longer update subscribers after being destroyed', () => {
Expand Down
161 changes: 157 additions & 4 deletions src/ControllerMessenger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,111 @@ describe('ControllerMessenger', () => {
expect(handler2.callCount).toStrictEqual(1);
});

it('should publish event with selector to subscriber', () => {
type MessageEvent = {
type: 'complexMessage';
payload: [Record<string, unknown>];
};
const controllerMessenger = new ControllerMessenger<never, MessageEvent>();

const handler = sinon.stub();
const selector = sinon.fake((obj: Record<string, unknown>) => obj.prop1);
controllerMessenger.subscribe('complexMessage', handler, selector);
controllerMessenger.publish('complexMessage', { prop1: 'a', prop2: 'b' });

expect(handler.calledWithExactly('a', undefined)).toStrictEqual(true);
expect(handler.callCount).toStrictEqual(1);
expect(
selector.calledWithExactly({ prop1: 'a', prop2: 'b' }),
).toStrictEqual(true);
expect(selector.callCount).toStrictEqual(1);
});

it('should call selector event handler with previous selector return value', () => {
type MessageEvent = {
type: 'complexMessage';
payload: [Record<string, unknown>];
};
const controllerMessenger = new ControllerMessenger<never, MessageEvent>();

const handler = sinon.stub();
const selector = sinon.fake((obj: Record<string, unknown>) => obj.prop1);
controllerMessenger.subscribe('complexMessage', handler, selector);
controllerMessenger.publish('complexMessage', { prop1: 'a', prop2: 'b' });
controllerMessenger.publish('complexMessage', { prop1: 'z', prop2: 'b' });

expect(handler.getCall(0).calledWithExactly('a', undefined)).toStrictEqual(
true,
);
expect(handler.getCall(1).calledWithExactly('z', 'a')).toStrictEqual(true);
expect(handler.callCount).toStrictEqual(2);
expect(
selector.getCall(0).calledWithExactly({ prop1: 'a', prop2: 'b' }),
).toStrictEqual(true);
expect(
selector.getCall(1).calledWithExactly({ prop1: 'z', prop2: 'b' }),
).toStrictEqual(true);
expect(selector.callCount).toStrictEqual(2);
});

it('should not publish event with selector if selector return value is unchanged', () => {
type MessageEvent = {
type: 'complexMessage';
payload: [Record<string, unknown>];
};
const controllerMessenger = new ControllerMessenger<never, MessageEvent>();

const handler = sinon.stub();
const selector = sinon.fake((obj: Record<string, unknown>) => obj.prop1);
controllerMessenger.subscribe('complexMessage', handler, selector);
controllerMessenger.publish('complexMessage', { prop1: 'a', prop2: 'b' });
controllerMessenger.publish('complexMessage', { prop1: 'a', prop3: 'c' });

expect(handler.calledWithExactly('a', undefined)).toStrictEqual(true);
expect(handler.callCount).toStrictEqual(1);
expect(
selector.getCall(0).calledWithExactly({ prop1: 'a', prop2: 'b' }),
).toStrictEqual(true);
expect(
selector.getCall(1).calledWithExactly({ prop1: 'a', prop3: 'c' }),
).toStrictEqual(true);
expect(selector.callCount).toStrictEqual(2);
});

it('should publish event to many subscribers with the same selector', () => {
type MessageEvent = {
type: 'complexMessage';
payload: [Record<string, unknown>];
};
const controllerMessenger = new ControllerMessenger<never, MessageEvent>();

const handler1 = sinon.stub();
const handler2 = sinon.stub();
const selector = sinon.fake((obj: Record<string, unknown>) => obj.prop1);
controllerMessenger.subscribe('complexMessage', handler1, selector);
controllerMessenger.subscribe('complexMessage', handler2, selector);
controllerMessenger.publish('complexMessage', { prop1: 'a', prop2: 'b' });
controllerMessenger.publish('complexMessage', { prop1: 'a', prop3: 'c' });

expect(handler1.calledWithExactly('a', undefined)).toStrictEqual(true);
expect(handler1.callCount).toStrictEqual(1);
expect(handler2.calledWithExactly('a', undefined)).toStrictEqual(true);
expect(handler2.callCount).toStrictEqual(1);
expect(
selector.getCall(0).calledWithExactly({ prop1: 'a', prop2: 'b' }),
).toStrictEqual(true);
expect(
selector.getCall(1).calledWithExactly({ prop1: 'a', prop2: 'b' }),
).toStrictEqual(true);
expect(
selector.getCall(2).calledWithExactly({ prop1: 'a', prop3: 'c' }),
).toStrictEqual(true);
expect(
selector.getCall(3).calledWithExactly({ prop1: 'a', prop3: 'c' }),
).toStrictEqual(true);
expect(selector.callCount).toStrictEqual(4);
});

it('should not call subscriber after unsubscribing', () => {
type MessageEvent = { type: 'message'; payload: [string] };
const controllerMessenger = new ControllerMessenger<never, MessageEvent>();
Expand All @@ -246,13 +351,30 @@ describe('ControllerMessenger', () => {
expect(handler.callCount).toStrictEqual(0);
});

it('should not call subscriber with selector after unsubscribing', () => {
type MessageEvent = {
type: 'complexMessage';
payload: [Record<string, unknown>];
};
const controllerMessenger = new ControllerMessenger<never, MessageEvent>();

const handler = sinon.stub();
const selector = sinon.fake((obj: Record<string, unknown>) => obj.prop1);
controllerMessenger.subscribe('complexMessage', handler, selector);
controllerMessenger.unsubscribe('complexMessage', handler);
controllerMessenger.publish('complexMessage', { prop1: 'a', prop2: 'b' });

expect(handler.callCount).toStrictEqual(0);
expect(selector.callCount).toStrictEqual(0);
});

it('should throw when unsubscribing when there are no subscriptions', () => {
type MessageEvent = { type: 'message'; payload: [string] };
const controllerMessenger = new ControllerMessenger<never, MessageEvent>();

const handler = sinon.stub();
expect(() => controllerMessenger.unsubscribe('message', handler)).toThrow(
"Subscription not found for event: 'message'",
'Subscription not found for event: message',
);
});

Expand All @@ -265,7 +387,7 @@ describe('ControllerMessenger', () => {
controllerMessenger.subscribe('message', handler1);

expect(() => controllerMessenger.unsubscribe('message', handler2)).toThrow(
"Subscription not found for event: 'message'",
'Subscription not found for event: message',
);
});

Expand Down Expand Up @@ -525,6 +647,37 @@ describe('RestrictedControllerMessenger', () => {
expect(handler.callCount).toStrictEqual(1);
});

it('should publish event with selector to subscriber', () => {
type MessageEvent = {
type: 'MessageController:complexMessage';
payload: [Record<string, unknown>];
};
const controllerMessenger = new ControllerMessenger<never, MessageEvent>();
const restrictedControllerMessenger = controllerMessenger.getRestricted({
name: 'MessageController',
allowedEvents: ['MessageController:complexMessage'],
});

const handler = sinon.stub();
const selector = sinon.fake((obj: Record<string, unknown>) => obj.prop1);
restrictedControllerMessenger.subscribe(
'MessageController:complexMessage',
handler,
selector,
);
restrictedControllerMessenger.publish('MessageController:complexMessage', {
prop1: 'a',
prop2: 'b',
});

expect(handler.calledWithExactly('a', undefined)).toStrictEqual(true);
expect(handler.callCount).toStrictEqual(1);
expect(
selector.calledWithExactly({ prop1: 'a', prop2: 'b' }),
).toStrictEqual(true);
expect(selector.callCount).toStrictEqual(1);
});

it('should allow publishing multiple different events to subscriber', () => {
type MessageEvent =
| { type: 'MessageController:message'; payload: [string] }
Expand Down Expand Up @@ -694,7 +847,7 @@ describe('RestrictedControllerMessenger', () => {
'MessageController:message',
handler,
),
).toThrow(`Subscription not found for event: 'MessageController:message'`);
).toThrow(`Subscription not found for event: MessageController:message`);
});

it('should throw when unsubscribing a handler that is not subscribed', () => {
Expand All @@ -720,7 +873,7 @@ describe('RestrictedControllerMessenger', () => {
'MessageController:message',
handler2,
),
).toThrow(`Subscription not found for event: 'MessageController:message'`);
).toThrow(`Subscription not found for event: MessageController:message`);
});

it('should not call subscriber after clearing event subscriptions', () => {
Expand Down
Loading

0 comments on commit a143120

Please sign in to comment.