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

Middleware support #8

Merged
merged 1 commit into from
Dec 25, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 44 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ subscribe(space, ({ newSpace }) => {
renderApp(space); // initial render
```

Since most changes to your application's state is caused by user interactions, it's very easy to bind _actions_ to events:
Since most changes to your application's state is caused by user interactions, its very easy to bind _actions_ to events:

```jsx
const clearName = ({ merge }, event) => {
Expand Down Expand Up @@ -183,7 +183,7 @@ const addTodo = ({ merge, space }, event) => {

```jsx
// You can rename the space to something more meaningful
// It's recommended that the prop still be called `space`, so when you’re
// it’s recommended that the prop still be called `space`, so when you’re
// working on the parent component, you’ll know it expects a space to be passed in
export default const Todo = ({ space: todo }) => {
const doneClassName = todo.done ? 'done' : '';
Expand Down Expand Up @@ -229,8 +229,8 @@ There are three ways to update spaces. Each method involves calling the space as
Immediately "changes" the space by shallowly merging the given object onto the space. A new space is returned with the changes applied and subscribers are immediately invoked.

```js
const space = createSpace({ name: 'Frodo', race: 'Hobbit' });
const newSpace = space({ name: 'Bilbo' }); // { name: 'Bilbo', race: 'Hobbit' }
const space = createSpace({ name: 'Frodo', race: 'Hobbit });
const newSpace = space({ name: 'Bilbo' }); // { name: 'Bilbo', race: 'Hobbit }
```

### Quick Actions (string)
Expand All @@ -254,7 +254,7 @@ export default const Todo = ({ space: todo }) => {

### Custom Actions (function)

A custom action is a function that is passed to a space. It returns a wrapped function. When the wrapped function is called, the action is called with a few named parameters (as the first actual parameter). The rest of the parameters are whatever is passed to the wrapped function when it's eventually called.
A custom action is a function that is passed to a space. It returns a wrapped function. When the wrapped function is called, the action is called with a few named parameters (as the first actual parameter). The rest of the parameters are whatever is passed to the wrapped function when its eventually called.

Every time you change a space within a custom action, subscribers will be notified. Every change you apply will apply on the latest version of the space.

Expand All @@ -272,7 +272,7 @@ Every time you change a space within a custom action, subscribers will be notifi

Custom actions can optionally be async functions. If they're async (i.e. return a promise), the wrapped action will also return a promise, which is resolved when the custom action is resolved. Return values like this are typically only needed when writing tests.

**Note**: After an `await`, the `space` that's passed in at the top of the action may be out of date, it's _highly_ recommended to always use `getSpace()` to get the latest version of the space after an `await` (or inside a callback).
**Note**: After an `await`, the `space` that's passed in at the top of the action may be out of date, its _highly_ recommended to always use `getSpace()` to get the latest version of the space after an `await` (or inside a callback).

### Arrays

Expand Down Expand Up @@ -329,6 +329,8 @@ const changeTodos = ({ merge, space }, itemToRemove) => {

### subscribe

Act on, or cancel, state changes.

Parameters:

1. A space that you want to subscribe to.
Expand All @@ -338,7 +340,8 @@ The subscriber function is called with the following named params:

* **newSpace**: The new space that was just created.
* **oldSpace**: The old version of the space that existing before it was changed.
* **causedBy**: A string container which part of the space triggered the change, and the name of the action responsible.
* **causedBy**: A string specifying which part of the space triggered the change, and the name of the action responsible.
* **cancel**: A function, when called, prevents future subsribers from being called.

```js
import { createSpace, subscribe } from 'spaceace';
Expand All @@ -351,6 +354,37 @@ subscribe(space, ({ newSpace, causedBy }) => {
});
```

* Subscribers can optionally **act as middleware**. If a subscriber returns an object, it will be turned into a space, and future subscribers will receive it as their `newSpace`.
* Subscribers are called in the order that they're subscribed, synchronously.
* Parent spaces receive the latest returned version after all child space subscribers are called.
* All future subscribers can be skipped by calling the passed in named param `cancel`.

If you want your "middleware" to not apply changes immediately, but instead do something async and then apply the changes, consider using [`cancel()`](#subscribe), and then applying changes using an [immediate update](#immediate-update-object) when you’re ready.

```js
subscribe(space.address, ({ oldSpace, newSpace, cancel }) => {
const stateCode = newSpace.stateCode.toUpperCase();
if (stateCode !== oldSpace.stateCode) {
if (!listOfUSStates.includes(stateCode)) {
// Cancel the update if the provided code is not allowed
cancel();
return;
}

// Alter the specified stateCode to uppercase
return {
...newSpace,
stateCode,
};
}
});

subscribe(space, ({ newSpace }) => {
// Receives `newSpace.address` with `stateCode` uppercase
renderApp(newSpace);
});
```

### isSpace

Returns: `true` if the given value is a space, `false` otherwise.
Expand Down Expand Up @@ -415,10 +449,10 @@ JSON.stringify(space); // '{ "user": "Frodo", "todos": [] }'

Even though the Redux DevTools broswer extension was originally made for Redux, it works with any immutable store, including SpaceAce!

It's as easy as:
it’s as easy as:

1. Install [Redux DevTools](http://extension.remotedev.io/) in your browser.
2. Hook up you root space to the extension, if it's detected:
2. Hook up you root space to the extension, if its detected:

```js
const rootSpace = createSpace({ [pageName]: {}, ...initialState, user });
Expand Down Expand Up @@ -459,7 +493,7 @@ I haven't encountered a need for it yet, please add an issue if you’re interes

Yes? Ok… you got me.

Each space is indeed frozen, all of its child spaces, arrays, and values are also frozen. If a space has changed, you can tell by doing an equality check between both versions, if they're equal to each other, then all their properties are guaranteed to be the identical. It's safe to pass a space into a [Pure Component](https://reactjs.org/docs/react-api.html#reactpurecomponent), for example.
Each space is indeed frozen, all of its child spaces, arrays, and values are also frozen. If a space has changed, you can tell by doing an equality check between both versions, if they're equal to each other, then all their properties are guaranteed to be the identical. it’s safe to pass a space into a [Pure Component](https://reactjs.org/docs/react-api.html#reactpurecomponent), for example.

But! There are a few hidden, non-enumerable, properties on every space (and array) that are mutable. They're not meant to be changed by you, they're used by SpaceAce to track subscribers and newer versions of the same space.

Expand Down
5 changes: 3 additions & 2 deletions lib/Space.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,15 @@ interface SubscriberParams {
newSpace: Space;
oldSpace?: Space;
causedBy?: string;
cancel?(): void;
}

interface Subscriber {
(subscriberParams: SubscriberParams): void;
(subscriberParams: SubscriberParams): object | void;
}

export function rootOf(space: Space): Space;
export function newestSpace(space: Space): Space;
export function isSpace(space: Space | any): boolean;
export function subscribe(subscriber: Subscriber): boolean;
export function subscribe(space: Space, subscriber: Subscriber): boolean;
export function createSpace(initialState: object): Space;
23 changes: 20 additions & 3 deletions lib/Space.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,14 +92,31 @@
}

function notifySubscribers(newSpace, oldSpace, causedBy) {
newSpace._subscribers.forEach(function(subscriber) {
subscriber({
var canceled = false;
function cancel() {
canceled = true;
}
newSpace._subscribers.every(function(subscriber) {
var result = subscriber({
newSpace: newSpace,
oldSpace: oldSpace,
causedBy: causedBy,
cancel: cancel,
});
if (result) {
if (isSpace(result)) newSpace = result;
if (isObject(result)) {
newSpace = new Space(
result,
newSpace._name,
newSpace._parent,
newSpace
);
}
}
return !canceled;
});
if (newSpace._parent) {
if (!canceled && newSpace._parent) {
var parentCausedBy;
if (isSimpleName(newSpace._name)) {
parentCausedBy = newSpace._name;
Expand Down
49 changes: 49 additions & 0 deletions test/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -513,4 +513,53 @@ describe('Space', function() {
assert(!this.newSpace.characters[1]);
});
});

describe('subscribe/middleware', function() {
it('is called in order', function() {
const results = [];
subscribe(this.space, () => results.push('first'));
subscribe(this.space, () => results.push('second'));
subscribe(this.space, () => results.push('third'));
assert.deepStrictEqual(results, []);
this.space({ someVal: true });
assert.deepStrictEqual(results, ['first', 'second', 'third']);
});

it('can cancel future subscribers', function() {
const results = [];
subscribe(this.space.userInfo, ({ cancel }) => {
results.push('first');
cancel();
});
subscribe(this.space.userInfo, () => results.push('second'));
this.space.userInfo({ someVal: true });
assert.deepStrictEqual(results, ['first']);

// parent subscribers should never be called
assert.strictEqual(this.numCalls, 0);
});

it('can provide replacement states', function() {
const lSpace = this.space.userInfo.location;
subscribe(lSpace, ({ newSpace }) => ({
...newSpace,
state: newSpace.state.toUpperCase(),
}));
subscribe(lSpace, ({ newSpace }) =>
assert.deepStrictEqual(
{ ...newSpace },
{
city: 'San Mateo',
state: 'PA',
country: 'USA',
}
)
);
lSpace({ state: 'pa' });

// parent gets latest version
assert.deepEqual(this.newSpace.userInfo.location.state, 'PA');
assert.strictEqual(this.numCalls, 1);
});
});
});