Skip to content

Commit

Permalink
Simplified serialization API
Browse files Browse the repository at this point in the history
  • Loading branch information
kirill-konshin committed Feb 7, 2023
1 parent 36e9619 commit 8433973
Show file tree
Hide file tree
Showing 8 changed files with 57 additions and 92 deletions.
2 changes: 0 additions & 2 deletions .yarnrc.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
yarnPath: .yarn/releases/yarn-3.4.1.cjs

nodeLinker: node-modules

pnpMode: loose
34 changes: 22 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -328,8 +328,7 @@ The `createWrapper` function accepts `makeStore` as its first argument. The `mak
`createWrapper` also optionally accepts a config object as a second parameter:

- `debug` (optional, boolean) : enable debug logging
- `serializeAction` and `deserializeAction` (optional, function): custom functions for serializing and deserializing the actions, see [Custom serialization and deserialization](#custom-serialization-and-deserialization)
- `actionFilter` (optional, function): filter out actions that should not be replayed on client, usually Redux Saga actions that cause subsequent actions
- `serialize` and `deserialize` (optional, function): custom functions for serializing and deserializing the actions, see [Custom serialization and deserialization](#custom-serialization-and-deserialization)

When `makeStore` is invoked it is provided with a Next.js context, which could be [`NextPageContext`](https://nextjs.org/docs/api-reference/data-fetching/getInitialProps) or [`AppContext`](https://nextjs.org/docs/advanced-features/custom-app) or [`getStaticProps`](https://nextjs.org/docs/basic-features/data-fetching#getstaticprops-static-generation) or [`getServerSideProps`](https://nextjs.org/docs/basic-features/data-fetching#getserversideprops-server-side-rendering) context depending on which lifecycle function you will wrap.

Expand Down Expand Up @@ -524,8 +523,10 @@ await store.dispatch(someAsyncAction());
## Custom serialization and deserialization

If you are storing complex types such as Immutable.JS or JSON objects in your state, a custom serialize and deserialize
handler might be handy to serialize the redux state on the server and deserialize it again on the client. To do so,
provide `serializeAction` and `deserializeAction` as config options to `createStore`.
handler might be handy to serialize the actions on the server and deserialize it again on the client. To do so,
provide `serialize` and `deserialize` as config options to `createStore`.

Both functions should take an array of actions and return an array of actions. `serialize` should remove all non-transferable objects and `deserialize` should return whatever your store can consume.

The reason is that state snapshot is transferred over the network from server to client as a plain object.

Expand All @@ -535,8 +536,8 @@ Example of a custom serialization of an Immutable.JS state using `json-immutable
const {serialize, deserialize} = require('json-immutable');

createWrapper({
serializeAction: action => ({...action, payload: serialize(action.payload)}),
deserializeAction: action => ({...action, payload: deserialize(action.payload)}),
serialize: actions => actions.map(action => ({...action, payload: serialize(action.payload)})),
deserialize: actions => actions.map(action => ({...action, payload: deserialize(action.payload)})),
});
```

Expand All @@ -546,16 +547,24 @@ Same thing using Immutable.JS:
const {fromJS} = require('immutable');

createWrapper({
serializeAction: action => ({...action, payload: action.payload.toJS()}),
deserializeAction: action => ({...action, payload: fromJS(action)}),
serialize: actions => actions.map(action => ({...action, payload: action.payload.toJS()})),
deserialize: actions => actions.map(action => ({...action, payload: fromJS(action)})),
});
```

You can also filter out actions that you don't want to dispatch on client (or even add actions that should only be dispatched on client, although latter it's not recommended). This approach may be useful for [sagas](#usage-with-redux-saga) to remove unnecessary actions from client:

```js
export const wrapper = createWrapper(makeStore, {
serialize: actions => actions.filter(action => action.type !== 'xxx'),
});
```

## Usage with Redux Saga

[Note, this method _may_ be unsafe - make sure you put a lot of thought into handling async sagas correctly. Race conditions happen very easily if you aren't careful.] To utilize Redux Saga, one simply has to make some changes to their `makeStore` function. Specifically, `redux-saga` needs to be initialized inside this function, rather than outside of it. (I did this at first, and got a nasty error telling me `Before running a Saga, you must mount the Saga middleware on the Store using applyMiddleware`). Here is how one accomplishes just that. This is just slightly modified from the setup example at the beginning of the docs. Keep in mind that this setup will opt you out of Automatic Static Optimization: https://err.sh/next.js/opt-out-auto-static-optimization.

Don't forget to filter out actions that cause saga to run using `actionFilter` config property.
Don't forget to filter out actions that cause saga to run using [`serialize`](#custom-serialization-and-deserialization) config property.

Create your root saga as usual, then implement the store creator:

Expand All @@ -580,9 +589,10 @@ export const makeStore = ({context, reduxWrapperMiddleware}) => {
return store;
};

const filterActions = ['@@redux-saga/CHANNEL_END', SAGA_ACTION];

export const wrapper = createWrapper(makeStore, {
debug: true,
actionFilter: action => action.type !== SAGA_ACTION, // don't forget to filter out actions that cause saga to run
serialize: actions => actions.filter(action => !filterActions.includes(action.type)), // !!! don't forget to filter out actions that cause saga to run
});
```

Expand Down Expand Up @@ -861,7 +871,7 @@ export default connect(state => state)(

6. All legacy HOCs are were removed, please use [custom ones](#usage-with-old-class-based-components) if you still need them, but I suggest to rewrite code into functional components and hooks

7. `serializeState` and `deserializeState` were removed, use `serializeAction` and `deserializeAction`
7. `serializeState` and `deserializeState` were removed, use `serialize` and `deserialize`

8. `const makeStore = (context) => {...}` is now `const makeStore = ({context, reduxWrapperMiddleware})`, you must add `reduxWrapperMiddleware` to your store

Expand Down
3 changes: 1 addition & 2 deletions packages/demo-saga-page/src/components/store.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,5 @@ const makeStore = ({reduxWrapperMiddleware}) => {
const filterActions = ['@@redux-saga/CHANNEL_END', SAGA_ACTION];

export const wrapper = createWrapper<SagaStore>(makeStore as any, {
debug: true,
actionFilter: action => !filterActions.includes(action.type),
serialize: actions => actions.filter(action => !filterActions.includes(action.type)),
});
3 changes: 1 addition & 2 deletions packages/demo-saga/src/components/store.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,5 @@ export const makeStore = ({reduxWrapperMiddleware}) => {
const filterActions = ['@@redux-saga/CHANNEL_END', SAGA_ACTION];

export const wrapper = createWrapper<SagaStore>(makeStore as any, {
debug: true,
actionFilter: action => !filterActions.includes(action.type),
serialize: actions => actions.filter(action => !filterActions.includes(action.type)),
});
30 changes: 11 additions & 19 deletions packages/wrapper/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,9 @@ const REQPROP = '__NEXT_REDUX_WRAPPER_STORE__';

const getIsServer = () => typeof window === 'undefined';

const getDeserializedAction = (action: any, {deserializeAction}: Config<any> = {}) =>
deserializeAction ? deserializeAction(action) : action;
const getDeserialized = (actions: Action[], {deserialize}: Config<any> = {}): Action[] => (deserialize ? deserialize(actions) : actions);

const getSerializedAction = (action: any, {serializeAction}: Config<any> = {}) => (serializeAction ? serializeAction(action) : action);

const getActionFilter =
({actionFilter}: Config<any> = {}) =>
(action: Action) =>
actionFilter ? actionFilter(action) : true;
const getSerialized = (actions: Action[], {serialize}: Config<any> = {}): Action[] => (serialize ? serialize(actions) : actions);

export declare type MakeStore<S extends Store> = (init: {context: Context; reduxWrapperMiddleware: Middleware}) => S;

Expand Down Expand Up @@ -69,9 +63,9 @@ const createMiddleware = (config: Config<any>): {log: Action[]; reduxWrapperMidd
return next(action);
}

action = JSON.parse(JSON.stringify(action, undefinedReplacer));
action = JSON.parse(JSON.stringify(action, undefinedReplacer)); //FIXME Let serialize take care of it?

log.push(getSerializedAction(action, config));
log.push(action);

return next(action);
};
Expand Down Expand Up @@ -175,13 +169,12 @@ const getStates = ({

const dispatchStates = (dispatch: Dispatch, states: PageProps, config: Config<any>) =>
getStates(states).forEach((actions, source) =>
actions.filter(getActionFilter(config)).forEach(action => {
action = getDeserializedAction(action, config);
getDeserialized(actions, config).forEach(action =>
dispatch({
...action,
meta: {...(action as any).meta, source},
});
}),
}),
),
);

export const createWrapper = <S extends Store>(makeStore: MakeStore<S>, config: Config<S> = {}) => {
Expand All @@ -198,10 +191,10 @@ export const createWrapper = <S extends Store>(makeStore: MakeStore<S>, config:
const initialProps = ((nextCallback && (await nextCallback(context))) || {}) as P;

if (config.debug) {
console.log(`2. initial state after dispatches`, store.getState());
console.log(`2. initial state after dispatches`, store.getState(), 'actions', log);
}

const reduxWrapperActions = [...log];
const reduxWrapperActions = getSerialized([...log], config);

log.splice(0, log.length); // flush all logs

Expand Down Expand Up @@ -388,10 +381,9 @@ export const createWrapper = <S extends Store>(makeStore: MakeStore<S>, config:
export type Context = NextPageContext | AppContext | GetStaticPropsContext | GetServerSidePropsContext;

export interface Config<S extends Store> {
serializeAction?: (state: ReturnType<S['getState']>) => any;
deserializeAction?: (state: any) => ReturnType<S['getState']>;
serialize?: (actions: Action[]) => Action[];
deserialize?: (actions: Action[]) => Action[];
debug?: boolean;
actionFilter?: (action: Action) => boolean | any;
}

export interface PageProps {
Expand Down
4 changes: 2 additions & 2 deletions packages/wrapper/tests/client.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ describe('client integration', () => {
test('API functions', async () => {
const Page = () => null;
Page.getInitialProps = wrapper.getInitialPageProps(s => () => null as any);
expect(await withStore(wrapper)(Page)?.getInitialProps({} as any)).toEqual({
expect(await Page.getInitialProps?.({} as any)).toEqual({
initialProps: {},
initialState: defaultState,
});
Expand Down Expand Up @@ -64,7 +64,7 @@ describe('client integration', () => {
});

// expected when invoked above
await withStore(wrapper)(Page)?.getInitialProps({
await Page.getInitialProps?.({
req: {},
res: {},
query: true,
Expand Down
68 changes: 20 additions & 48 deletions packages/wrapper/tests/server.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ describe('function API', () => {
return {pageProps};
});

const resultingProps = await withStore(wrapper)(App)?.getInitialProps(nextJsContext());
const resultingProps = await App.getInitialProps(nextJsContext());

//TODO Test missing context items
expect(resultingProps).toEqual({
Expand Down Expand Up @@ -125,7 +125,7 @@ describe('function API', () => {
return {pageProps: {fromApp: true}};
});

const initialAppProps = await withStore(wrapper)(App)?.getInitialProps(context);
const initialAppProps = await App.getInitialProps(context);

expect(initialAppProps).toEqual({
pageProps: {
Expand Down Expand Up @@ -189,7 +189,7 @@ describe('function API', () => {
return {pageProps: {fromApp: true}};
});

const initialAppProps = await withStore(wrapper)(App)?.getInitialProps(context);
const initialAppProps = await App.getInitialProps(context);

expect(initialAppProps).toEqual({
pageProps: {
Expand Down Expand Up @@ -253,7 +253,7 @@ describe('function API', () => {
return {pageProps: {fromApp: true}};
});

const initialAppProps = await withStore(wrapper)(App)?.getInitialProps(context);
const initialAppProps = await App.getInitialProps(context);

expect(initialAppProps).toEqual({
pageProps: {
Expand All @@ -269,7 +269,7 @@ describe('function API', () => {
return {fromGip: true};
});

const initialPageProps = await withStore(wrapper)(Page).getInitialProps(context.ctx);
const initialPageProps = await Page.getInitialProps?.(context.ctx);

expect(initialPageProps).toEqual({
fromGip: true,
Expand Down Expand Up @@ -313,7 +313,7 @@ describe('function API', () => {
return pageProps;
});

const resultingProps = await withStore(wrapper)(Page).getInitialProps(nextJsContext().ctx);
const resultingProps = await Page.getInitialProps?.(nextJsContext().ctx);

expect(resultingProps).toEqual({
prop: 'val',
Expand All @@ -337,18 +337,27 @@ describe('function API', () => {

describe('custom serialization', () => {
test('serialize on server and deserialize on client', async () => {
const serializeAction = jest.fn((a: any) => ({...a, payload: JSON.stringify(a.payload)}));
const deserializeAction = jest.fn((a: any) => ({...a, payload: JSON.parse(a.payload)}));
const serialize = jest.fn((actions: any) => {
console.log(actions);
return actions.map((a: any) => ({...a, payload: JSON.stringify(a.payload)}));
});
const deserialize = jest.fn((actions: any) => actions.map((a: any) => ({...a, payload: JSON.parse(a.payload)})));

const wrapper = createWrapper(makeStore, {serializeAction, deserializeAction});
const wrapper = createWrapper(makeStore, {
serialize,
deserialize,
debug: true, // just for sake of coverage
});

const Page = () => null;
Page.getInitialProps = wrapper.getInitialPageProps(store => () => {
store.dispatch(action);
return pageProps;
});

const props = await withStore(wrapper)(Page)?.getInitialProps(nextJsContext().ctx);
const props = await Page.getInitialProps?.(nextJsContext().ctx);

expect(serialize).toBeCalled();

expect(props).toEqual({
...pageProps,
Expand Down Expand Up @@ -378,43 +387,6 @@ describe('custom serialization', () => {
}),
);

expect(serializeAction).toBeCalled();
expect(deserializeAction).toBeCalled();
});
});

describe('action filter', () => {
test('skips actions if filter removes them', async () => {
const wrapper = createWrapper(makeStore, {
actionFilter: (a: any) => a.payload !== actionAPP.payload,
debug: true, // just for sake of coverage
});

const Page = () => null;
Page.getInitialProps = wrapper.getInitialPageProps(store => () => {
store.dispatch(action);
store.dispatch(actionAPP);
return pageProps;
});

const props = await withStore(wrapper)(Page)?.getInitialProps(nextJsContext().ctx);

expect(props).toEqual({
...pageProps,
reduxWrapperActionsGIPP: [action, actionAPP],
});

const WrappedApp: any = withStore(wrapper)(DummyComponent);

expect(child(<WrappedApp {...props} wrapper={wrapper} />)).toEqual(
JSON.stringify({
props: {
...pageProps,
reduxWrapperActionsGIPP: [action, actionAPP],
wrapper: {},
},
state: {reduxStatus: action.payload},
}),
);
expect(deserialize).toBeCalled();
});
});
5 changes: 0 additions & 5 deletions packages/wrapper/tests/testlib.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,5 @@ export const withStore = (w: any) => (Component: any) => {

WrappedComponent.displayName = `withRedux(${Component.displayName || Component.name || 'Component'})`;

// also you can use hoist-non-react-statics package
if ('getInitialProps' in Component) {
WrappedComponent.getInitialProps = Component.getInitialProps;
}

return WrappedComponent;
};

0 comments on commit 8433973

Please sign in to comment.