Skip to content

Commit

Permalink
feat: add context enhancer and applyMiddleware (#17)
Browse files Browse the repository at this point in the history
* chore: enhance scaffolding

* chore: apply middleware enhancer

* chore: test enhanced context

* chore: update create context api

* chore: docs and test applymiddleware
  • Loading branch information
iamogbz committed Sep 12, 2020
1 parent 43b3ace commit b67ebcd
Show file tree
Hide file tree
Showing 13 changed files with 298 additions and 65 deletions.
38 changes: 36 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,16 @@ Create the global context.

```js
// context.js
export default createContext(rootDuck.reducer, rootDuck.initialState);
export default createContext(
rootDuck.reducer,
rootDuck.initialState,
"ContextName",
enhancer
);
```

**Note:** The enhancer may be optionally specified to enhance the context with third-party capabilities such as middleware, time travel, persistence, etc. The only context enhancer that ships with Ducks is [applyMiddleware](#applyMiddlewaremiddlewares).

Use the state and actions in your component.

```jsx
Expand Down Expand Up @@ -111,6 +118,34 @@ const rootElement = document.getElementById("root");

A side benefit to scoping the context state to the provider is allowing multiple entire apps to be run concurrently.

### applyMiddleware(...middlewares)

This takes a variable list of middlewares to be applied

#### Example: Custom Logger Middleware

```js
// context.js
function logger({ getState }) {
return (next) => (action) => {
console.log("will dispatch", action);
// Call the next dispatch method in the middleware chain.
const returnValue = next(action);
Promise.resolve().then(() => {
// The state is updated by `React.useReducer` in the next tick
console.log("state after dispatch", getState());
});
// This will likely be the action itself, unless
// a middleware further in chain changed it.
return returnValue;
};
}

export default createContext(..., applyMiddleware(logger));
```

See [redux applyMiddleware][redux-applymiddleware] for more documentation.

## Example

As a proof of concept converted the sandbox app from the react-redux basic tutorial
Expand All @@ -121,7 +156,6 @@ As a proof of concept converted the sandbox app from the react-redux basic tutor
## Next

- Implement slice selectors and `useSelector` hook, [reference][react-redux-useselector]
- Implement asynchronous middleware context support, [reference][redux-applymiddleware]
- Implement observable pattern for context value, [reference][proposal-observable]

## Suggestions
Expand Down
15 changes: 9 additions & 6 deletions src/components/Provider.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as React from "react";
import { ActionTypes } from "../utils/actionTypes";
import { createAction } from "../createAction";
import { useGetter } from "src/hooks/useGetter";
import { ActionTypes } from "src/utils/actionTypes";
import { createAction } from "src/createAction";

export function Provider<S, T extends string, P>({
children,
Expand All @@ -9,6 +10,7 @@ export function Provider<S, T extends string, P>({
const root = React.useContext(Context);

const [state, reducerDispatch] = React.useReducer(root.reducer, root.state);
const getState = useGetter(state);

const dispatch = React.useCallback<ContextValue<S, T, P>["dispatch"]>(
function wrappedDispatch(action) {
Expand All @@ -18,10 +20,11 @@ export function Provider<S, T extends string, P>({
[reducerDispatch],
);

const enhanced = React.useMemo<ContextValue<S, T, P>>(
() => ({ ...root, dispatch }),
[root, dispatch],
);
const enhanced = React.useMemo<ContextValue<S, T, P>>(() => {
const { enhancer, ...value } = root;
Object.assign(value, { dispatch, getState });
return enhancer?.(value) ?? value;
}, [dispatch, getState, root]);

React.useEffect(
function initialiseContext() {
Expand Down
3 changes: 3 additions & 0 deletions src/createContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,14 @@ function createUnimplemented(objectName?: string): (m: string) => () => never {
export function createContext<S, T extends string, P>(
rootReducer: Reducer<S, T, P>,
preloadedState: S,
enhancer?: ContextEnhance<S, T, P>,
displayName?: string,
): Context<S, T, P> {
const unimplemented = createUnimplemented(`Context(${displayName ?? ""})`);
const Context = React.createContext<ContextValue<S, T, P>>({
dispatch: (a) => a,
enhancer,
getState: () => preloadedState,
reducer: rootReducer,
state: preloadedState,
[SymbolObservable]: unimplemented(SymbolObservable.toString()),
Expand Down
14 changes: 14 additions & 0 deletions src/hooks/useGetter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import * as React from "react";

export function useGetter<R>(value: R): () => R {
const ref = React.useRef(value);
React.useEffect(() => {
ref.current = value;
}, [value]);
return React.useCallback(
function getValue() {
return ref.current;
},
[ref],
);
}
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
export { Provider } from "./components/Provider";
export { applyMiddleware } from "./utils/applyMiddleware";
export { createAction } from "./createAction";
export { createContext } from "./createContext";
export { createDuck } from "./createDuck";
export { createReducer } from "./createReducer";
export { createRootDuck } from "./createRootDuck";
export { createRootProvider } from "./createRootProvider";
export { Provider } from "./components/Provider";
32 changes: 32 additions & 0 deletions src/utils/applyMiddleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// github.com/reduxjs/redux/blob/8551ba8/src/applyMiddleware.ts
import { compose } from "./compose";

export function applyMiddleware<S, T extends string, P>(
...middlewares: Middleware<S, T, P>[]
): ContextEnhance<S, T, P> {
return function enhancer(
context: ContextValue<S, T, P>,
): ContextValue<S, T, P> {
const dispatchStub: ContextDispatch<T, P> = () => {
throw new Error(
"Dispatching while constructing your middleware is not allowed. " +
"Other middleware would not be applied to this dispatch.",
);
};

const middlewareAPI: MiddlewareAPI<S, T, P> = {
getState: context.getState,
dispatch: (action, ...args) => dispatchStub(action, ...args),
};
const chain = middlewares.map((middleware) =>
middleware(middlewareAPI),
);
const dispatch = compose<typeof context.dispatch>(...chain)(
context.dispatch,
);
return {
...context,
dispatch,
};
};
}
60 changes: 60 additions & 0 deletions src/utils/compose.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// github.com/reduxjs/redux/blob/f1fc7ce/src/compose.ts
type Func<T extends unknown[], R> = (...a: T) => R;

/**
* Composes single-argument functions from right to left. The rightmost
* function can take multiple arguments as it provides the signature for the
* resulting composite function.
*
* @param funcs The functions to compose.
* @returns A function obtained by composing the argument functions from right
* to left. For example, `compose(f, g, h)` is identical to doing
* `(...args) => f(g(h(...args)))`.
*/
export function compose(): <R>(a: R) => R;

export function compose<F extends Function>(f: F): F;

/* two functions */
export function compose<A, T extends unknown[], R>(
f1: (a: A) => R,
f2: Func<T, A>,
): Func<T, R>;

/* three functions */
export function compose<A, B, T extends unknown[], R>(
f1: (b: B) => R,
f2: (a: A) => B,
f3: Func<T, A>,
): Func<T, R>;

/* four functions */
export function compose<A, B, C, T extends unknown[], R>(
f1: (c: C) => R,
f2: (b: B) => C,
f3: (a: A) => B,
f4: Func<T, A>,
): Func<T, R>;

/* rest */
export function compose<R>(
f1: (a: unknown) => R,
...funcs: Function[]
): (...args: unknown[]) => R;

export function compose<R>(...funcs: Function[]): (...args: unknown[]) => R;

export function compose(...funcs: Function[]): Function {
if (funcs.length === 0) {
// infer the argument type so it is usable in inference down the line
return <T>(arg: T): T => arg;
}

if (funcs.length === 1) {
return funcs[0];
}

return funcs.reduce(
(a, b) => ((...args: unknown[]) => a(b(...args))) as typeof a,
);
}
2 changes: 2 additions & 0 deletions tests/__snapshots__/createContext.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
exports[`createContext has unimplemented observable symbol 1`] = `
Object {
"dispatch": [Function],
"enhancer": undefined,
"getState": [Function],
"reducer": [Function],
"state": Object {},
Symbol(@@observable): [Function],
Expand Down
2 changes: 1 addition & 1 deletion tests/createContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ describe("createContext", () => {
});

it("creates context with displayname", () => {
const Context = createContext((s) => s, {}, "TextContext");
const Context = createContext((s) => s, {}, undefined, "TextContext");
expect(Context.displayName).toEqual("TextContext");
});

Expand Down
84 changes: 35 additions & 49 deletions tests/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,58 +1,21 @@
import * as React from "react";
import { act, cleanup, render } from "@testing-library/react";
import {
createContext,
createDuck,
createRootDuck,
createRootProvider,
} from "src";
import { ActionTypes } from "src/utils/actionTypes";
import { Provider } from "src";
import { createMocks } from "./mocks";

describe("e2e", (): void => {
const increment = jest.fn((state: number): number => state + 1);
const decrement = (state: number): number => state - 1;
const counterDuck = createDuck({
name: "counter",
initialState: 0,
reducers: { decrement, increment },
});

const init = jest.fn((): boolean => true);
const initDuck = createDuck({
name: "init",
initialState: false,
reducers: { init },
actionMapping: { [ActionTypes.INIT]: "init" },
});

const rootDuck = createRootDuck(counterDuck, initDuck);

const Context = createContext(
rootDuck.reducer,
rootDuck.initialState,
"TestContext",
);

const Provider = createRootProvider(Context);

function Example(): React.ReactElement {
const { state, dispatch } = React.useContext(Context);
const increment = React.useCallback(() => {
dispatch(counterDuck.actions.increment());
}, [dispatch]);
return (
<div>
Count: <span>{state[counterDuck.name]}</span>
<button disabled={!state[initDuck.name]} onClick={increment}>
increment
</button>
</div>
);
}
const {
EnhancedContext,
Example,
RootProvider,
increment,
init,
} = createMocks();

afterEach(() => {
increment.mockClear();
init.mockClear();
jest.clearAllMocks();
cleanup();
});

Expand All @@ -67,14 +30,37 @@ describe("e2e", (): void => {

it("Renders with root provider and updates on action dispatch", async () => {
const result = render(
<Provider>
<RootProvider>
<Example />
</Provider>,
</RootProvider>,
);
await act(() => result.findByText("increment").then((e) => e.click()));
expect(increment).toHaveBeenCalled();
await act(() => result.findByText("increment").then((e) => e.click()));
expect(result.baseElement).toMatchSnapshot();
expect(init).toHaveBeenCalledTimes(1);
});

it("Renders with enhanced context", async () => {
const spyLog = jest.spyOn(console, "log");
render(
<Provider Context={EnhancedContext}>
<Example />
</Provider>,
);
await Promise.resolve();
expect(spyLog).toHaveBeenCalledTimes(2);
expect(spyLog.mock.calls[0]).toEqual([
"action to dispatch",
{
payload: undefined,
type: expect.stringContaining("@@context/INIT"),
},
]);
expect(spyLog.mock.calls[1]).toEqual([
"state after dispatch",
{ counter: 0, init: true },
]);
spyLog.mockRestore();
});
});
Loading

0 comments on commit b67ebcd

Please sign in to comment.