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

Improved mutator API #100

Open
wants to merge 15 commits into
base: master
Choose a base branch
from
31 changes: 31 additions & 0 deletions src/CombinedMutator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import ActionMessage from './interfaces/ActionMessage';
import MutatorMap from './interfaces/MutatorMap';

export default class CombinedMutator<TState extends { [key: string]: any }> {
private mutatorKeys: string[];

constructor(private mutatorMap: MutatorMap<TState>) {
this.mutatorKeys = Object.keys(this.mutatorMap);
}

getInitialValue() {
let initialValue: TState = <TState>{};
this.mutatorKeys.forEach(key => {
initialValue[key] = this.mutatorMap[key].getInitialValue();
});

return initialValue;
}

handleAction(
currentState: TState,
actionMessage: ActionMessage,
replaceState: (newState: TState) => void
) {
this.mutatorKeys.forEach(key => {
this.mutatorMap[key].handleAction(currentState[key], actionMessage, newState => {
currentState[key] = newState;
});
});
}
}
47 changes: 47 additions & 0 deletions src/LeafMutator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import ActionCreator from './interfaces/ActionCreator';
import ActionMessage from './interfaces/ActionMessage';
import { getPrivateActionId } from './actionCreator';

export type MutatorHandler<TState, TAction extends ActionMessage> = (
state: TState,
action: TAction
) => TState | void;

// Represents a mutator for a leaf node in the state tree
export default class LeafMutator<TState> {
private handlers: { [actionId: string]: MutatorHandler<TState, ActionMessage> } = {};

constructor(private initialValue: TState) {}

getInitialValue() {
return this.initialValue;
Copy link
Contributor

@MLoughry MLoughry Jul 6, 2018

Choose a reason for hiding this comment

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

Is it a concern that if a mutable value is passed, the initialValue could change over time? #Resolved

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is a good point. No, I wasn't worried about this because MobX creates a clone of the value rather than allowing the original value to be modified. However, this is only true of more recent versions of MobX. So this could be busted in the v3 branch of satchel because it allows use of MobX 2. That's a bummer, because I had intended to cherry-pick this back into v3. I'm not sure if this is a big enough gotcha to avoid doing that or not.


In reply to: 200790349 [](ancestors = 200790349)

}

handles<TAction extends ActionMessage>(
actionCreator: ActionCreator<TAction>,
handler: MutatorHandler<TState, TAction>
) {
let actionId = getPrivateActionId(actionCreator);
if (this.handlers[actionId]) {
throw new Error('A mutator may not handle the same action twice.');
}

this.handlers[actionId] = handler;
return this;
}

handleAction(
currentState: TState,
actionMessage: ActionMessage,
replaceState: (newState: TState) => void
) {
let actionId = getPrivateActionId(actionMessage);
let handler = this.handlers[actionId];
if (handler) {
let returnValue = handler(currentState, actionMessage);
if (returnValue !== undefined) {
Copy link
Contributor

@MLoughry MLoughry Jul 6, 2018

Choose a reason for hiding this comment

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

This is part of the reason I don't like the ability to return a replacement value, especially in addition to mutating the value.

  1. It is totally valid to set a value to undefined. However, if you return undefined, then nothing happens (due to seeming black magic)
  2. If you have a handler like so:
    .handles(actionA, (state, action) =>
          state.someProperty = 'A';
      )
    This actually results in replacing the entire state with 'A', rather than merely mutating it. All because of the lack of curly braces. #Pending

Copy link
Contributor Author

Choose a reason for hiding this comment

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

  1. The point about undefined is inarguable and is, I think, a pitfall that is pretty rarely relevant but people will need to be aware of. It's akin to the MobX gotcha around not being able to observe properties that don't exist.
  2. Possible in pure JS, but TypeScript would flag this as an error because your return value would be of the wrong type.

My personal feeling is that these downsides aren't strong enough to clutter up the API with two different methods, but that would be the alternative. I'd like to gather a few more opinions and then settle on which way to do it.


In reply to: 200790946 [](ancestors = 200790946)

replaceState(<TState>returnValue);
}
}
}
}
6 changes: 6 additions & 0 deletions src/combineMutators.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import MutatorMap from './interfaces/MutatorMap';
import CombinedMutator from './CombinedMutator';

export default function combineMutators<TState>(mutatorMap: MutatorMap<TState>) {
return new CombinedMutator(mutatorMap);
}
5 changes: 5 additions & 0 deletions src/createMutator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import LeafMutator from './LeafMutator';

export default function createMutator<TState>(initialValue: TState) {
return new LeafMutator(initialValue);
}
37 changes: 35 additions & 2 deletions src/createStore.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { action } from 'mobx';
import getRootStore from './getRootStore';
import CombinedMutator from './CombinedMutator';
import LeafMutator from './LeafMutator';
import { subscribeAll } from './dispatcher';
import wrapMutator from './wrapMutator';

let createStoreAction = action('createStore', function createStoreAction(
key: string,
Expand All @@ -8,7 +12,36 @@ let createStoreAction = action('createStore', function createStoreAction(
getRootStore().set(key, initialState);
});

export default function createStore<T>(key: string, initialState: T): () => T {
export default function createStore<TState>(
key: string,
arg2: TState | LeafMutator<TState> | CombinedMutator<TState>
): () => TState {
// Get the initial state (from the mutator, if necessary)
let mutator = getMutator(arg2);
let initialState = mutator ? mutator.getInitialValue() : arg2;

// Create the store under the root store
createStoreAction(key, initialState);
return () => <T>getRootStore().get(key);
let getStore = () => <TState>getRootStore().get(key);

// If necessary, hook the mutator up to the dispatcher
if (mutator) {
subscribeAll(
Copy link
Contributor

@MLoughry MLoughry Jul 6, 2018

Choose a reason for hiding this comment

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

This seems really non-performant, if every mutator created in this new API is listening for every action. #Resolved

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Given that this is all just pure JS and that for mutators that don't care about the action it's basically a NOP, I'm not too worried. If it does turn out to be a problem I could imagine a way to have the mutators expose what actions they care about so we could subscribe to them individually...but I'm not going to add that complexity unless we know it's a problem.


In reply to: 200791963 [](ancestors = 200791963)

wrapMutator(actionMessage => {
mutator.handleAction(getStore(), actionMessage, newState => {
getRootStore().set(key, newState);
});
})
);
}

return getStore;
}

function getMutator<TState>(mutator: TState | LeafMutator<TState> | CombinedMutator<TState>) {
Copy link
Contributor

@MLoughry MLoughry Jul 6, 2018

Choose a reason for hiding this comment

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

Would it make more sense to implement a type guard method instead?

function getMutator<TState>(mutator: TState | LeafMutator<TState> | CombinedMutator<TState>): mutator is LeafMutator<TState> | CombinedMutator<TState> {
  let typedMutator = <LeafMutator<TState> | CombinedMutator<TState>>mutator;
  return !!(typedMutator.handleAction && typedMutator.getInitialValue);
} #WontFix

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I could, but the code here doesn't lend itself so well to the if {...} else {...} structure that a type guard generally follows. IMHO assigning it to a new mutator variable makes the code clearer.


In reply to: 200791792 [](ancestors = 200791792)

if ((<any>mutator).handleAction) {
return <LeafMutator<TState> | CombinedMutator<TState>>mutator;
Copy link
Contributor

@MLoughry MLoughry Jul 6, 2018

Choose a reason for hiding this comment

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

Would it make sense for LeafMutator and CombinedMutator to implement a common interface? #WontFix

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I went back and forth on this... The problem is that LeafMutator has an extra method that CombinedMutator doesn't, so I still need the package to export that type. I could create an interface for the stuff that's common, but it's just one more type that needs to be exported.


In reply to: 200791352 [](ancestors = 200791352)

}

return null;
}
25 changes: 18 additions & 7 deletions src/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Subscriber from './interfaces/Subscriber';
import { getPrivateActionId } from './actionCreator';
import { getGlobalContext } from './globalContext';

export function subscribe(actionId: string, callback: Subscriber<any>) {
export function subscribe(actionId: string, callback: Subscriber<ActionMessage>) {
let subscriptions = getGlobalContext().subscriptions;
if (!subscriptions[actionId]) {
subscriptions[actionId] = [];
Expand All @@ -12,6 +12,10 @@ export function subscribe(actionId: string, callback: Subscriber<any>) {
subscriptions[actionId].push(callback);
}

export function subscribeAll(callback: Subscriber<ActionMessage>) {
getGlobalContext().subscriptionsToAll.push(callback);
}

export function dispatch(actionMessage: ActionMessage) {
if (getGlobalContext().inMutator) {
throw new Error('Mutators cannot dispatch further actions.');
Expand All @@ -23,20 +27,27 @@ export function dispatch(actionMessage: ActionMessage) {

export function finalDispatch(actionMessage: ActionMessage): void | Promise<void> {
let actionId = getPrivateActionId(actionMessage);
let subscribers = getGlobalContext().subscriptions[actionId];
let promises: Promise<any>[] = [];

// Callback subscribers to specific actions
let subscribers = getGlobalContext().subscriptions[actionId];
if (subscribers) {
let promises: Promise<any>[] = [];

subscribers.forEach(subscriber => {
let returnValue = subscriber(actionMessage);
if (returnValue) {
promises.push(returnValue);
}
});
}

// Callback subscribers to all actions
getGlobalContext().subscriptionsToAll.forEach(subscriber => {
// These subscribers must be mutators, which cannot be async
subscriber(actionMessage);
});

if (promises.length) {
return promises.length == 1 ? promises[0] : Promise.all(promises);
}
// If multiple promises are returned, merge them
if (promises.length) {
return promises.length == 1 ? promises[0] : Promise.all(promises);
}
}
2 changes: 2 additions & 0 deletions src/globalContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface GlobalContext {
rootStore: ObservableMap<any>;
nextActionId: number;
subscriptions: { [key: string]: Subscriber<ActionMessage>[] };
subscriptionsToAll: Subscriber<ActionMessage>[];
dispatchWithMiddleware: DispatchFunction;
inMutator: boolean;

Expand All @@ -38,6 +39,7 @@ export function __resetGlobalContext() {
rootStore: observable.map({}),
nextActionId: 0,
subscriptions: {},
subscriptionsToAll: [],
dispatchWithMiddleware: null,
inMutator: false,
legacyInDispatch: 0,
Expand Down
7 changes: 5 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@ export { default as MutatorFunction } from './interfaces/MutatorFunction';
export { default as OrchestratorFunction } from './interfaces/OrchestratorFunction';
export { action, actionCreator } from './actionCreator';
export { default as applyMiddleware } from './applyMiddleware';
export { default as createMutator } from './createMutator';
export { default as combineMutators } from './combineMutators';
export { default as createStore } from './createStore';
export { dispatch } from './dispatcher';
export { default as mutator } from './mutator';
import { default as orchestrator } from './orchestrator';
export { default as mutator } from './mutatorDecorator';
export { default as LeafMutator } from './LeafMutator';
import { default as orchestrator } from './orchestratorDecorator';
export { default as getRootStore } from './getRootStore';
export { mutatorAction, orchestratorAction } from './simpleSubscribers';
export { useStrict };
Expand Down
8 changes: 8 additions & 0 deletions src/interfaces/MutatorMap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import LeafMutator from '../LeafMutator';
import CombinedMutator from '../CombinedMutator';

type MutatorMap<TState extends { [key: string]: any }> = {
[K in keyof TState]: LeafMutator<TState[K]> | CombinedMutator<TState[K]>
};

export default MutatorMap;
16 changes: 3 additions & 13 deletions src/mutator.ts → src/mutatorDecorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import MutatorFunction from './interfaces/MutatorFunction';
import { getPrivateActionId } from './actionCreator';
import { subscribe } from './dispatcher';
import { getGlobalContext } from './globalContext';
import wrapMutator from './wrapMutator';

export default function mutator<T extends ActionMessage>(
actionCreator: ActionCreator<T>,
Expand All @@ -16,20 +17,9 @@ export default function mutator<T extends ActionMessage>(
throw new Error('Mutators can only subscribe to action creators.');
}

// Wrap the callback in a MobX action so it can modify the store
let wrappedTarget = action((actionMessage: T) => {
try {
getGlobalContext().inMutator = true;
if (target(actionMessage)) {
throw new Error('Mutators cannot return a value and cannot be async.');
}
} finally {
getGlobalContext().inMutator = false;
}
});

// Subscribe to the action
subscribe(actionId, wrappedTarget);
subscribe(actionId, wrapMutator(target));

// Return the original function so it can be exported for tests
return target;
}
File renamed without changes.
4 changes: 2 additions & 2 deletions src/simpleSubscribers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import ActionCreator from './interfaces/ActionCreator';
import SimpleAction from './interfaces/SimpleAction';
import Subscriber from './interfaces/Subscriber';
import { action } from './actionCreator';
import mutator from './mutator';
import orchestrator from './orchestrator';
import mutator from './mutatorDecorator';
import orchestrator from './orchestratorDecorator';

export function createSimpleSubscriber(decorator: Function) {
return function simpleSubscriber<T extends SimpleAction>(actionType: string, target: T): T {
Expand Down
21 changes: 21 additions & 0 deletions src/wrapMutator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { action } from 'mobx';
import ActionMessage from './interfaces/ActionMessage';
import MutatorFunction from './interfaces/MutatorFunction';
import { getGlobalContext } from './globalContext';

// Wraps the target function for use as a mutator
export default function wrapMutator<T extends ActionMessage>(
target: MutatorFunction<T>
): MutatorFunction<T> {
// Wrap the target in a MobX action so it can modify the store
return action((actionMessage: T) => {
try {
getGlobalContext().inMutator = true;
if (target(actionMessage)) {
throw new Error('Mutators cannot return a value and cannot be async.');
Copy link
Contributor

@MLoughry MLoughry Jul 6, 2018

Choose a reason for hiding this comment

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

This seems to contradict the fact that you can return a value in a createMutator().handles() handler #Resolved

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, I can see how this is confusing. Effectively this will only ever apply to the existing mutators (which are functions), not the new-style mutators (which are objects that can have handler functions registered onto them).

Fortunately there should be no confusion to the consumer -- if they're using the new style mutators they'll never see this, and if they do see it with an old-style mutator the callstack will make it obvious where it's coming from.


In reply to: 200792183 [](ancestors = 200792183)

}
} finally {
getGlobalContext().inMutator = false;
}
});
}
59 changes: 59 additions & 0 deletions test/CombinedMutatorTests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { actionCreator } from '../src/index';
import CombinedMutator from '../src/CombinedMutator';
import createMutator from '../src/createMutator';

describe('CombinedMutator', () => {
const mutatorA = createMutator('a');
const mutatorB = createMutator('b');

it('combines the initial values of its child mutators', () => {
// Act
let combinedMutator = new CombinedMutator({
A: mutatorA,
B: mutatorB,
});

// Assert
expect(combinedMutator.getInitialValue()).toEqual({ A: 'a', B: 'b' });
});

it('dispatches actions to each child mutator', () => {
// Arrange
const actionMessage = {};
const spyA = spyOn(mutatorA, 'handleAction');
const spyB = spyOn(mutatorB, 'handleAction');

let combinedMutator = new CombinedMutator({
A: mutatorA,
B: mutatorB,
});

// Act
combinedMutator.handleAction({ A: 'a', B: 'b' }, actionMessage, null);

// Assert
expect(spyA.calls.argsFor(0)[0]).toBe('a');
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: You can write these asserts as

expect(spyA).toHaveBeenCalledWith('a', actionMessage);
expect(spyB).toHaveBeenCalledWith('b', actionMessage);

expect(spyA.calls.argsFor(0)[1]).toBe(actionMessage);
expect(spyB.calls.argsFor(0)[0]).toBe('b');
expect(spyB.calls.argsFor(0)[1]).toBe(actionMessage);
});

it('replaces a child state when the replaceState callback gets called', () => {
// Arrange
spyOn(
mutatorA,
'handleAction'
).and.callFake((state: any, actionMessage: any, replaceState: Function) => {
replaceState('x');
});

let state = { A: 'a' };
let combinedMutator = new CombinedMutator({ A: mutatorA });

// Act
combinedMutator.handleAction(state, {}, null);

// Assert
expect(state.A).toBe('x');
});
});
Loading