From 84339733a54ac57d3883924dcf08255c0dbe7ffb Mon Sep 17 00:00:00 2001 From: Kirill Konshin Date: Mon, 6 Feb 2023 19:50:08 -1000 Subject: [PATCH] Simplified serialization API --- .yarnrc.yml | 2 - README.md | 34 ++++++---- .../demo-saga-page/src/components/store.tsx | 3 +- packages/demo-saga/src/components/store.tsx | 3 +- packages/wrapper/src/index.tsx | 30 +++----- packages/wrapper/tests/client.spec.tsx | 4 +- packages/wrapper/tests/server.spec.tsx | 68 ++++++------------- packages/wrapper/tests/testlib.tsx | 5 -- 8 files changed, 57 insertions(+), 92 deletions(-) diff --git a/.yarnrc.yml b/.yarnrc.yml index 4ee0691..277ec1c 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -1,5 +1,3 @@ yarnPath: .yarn/releases/yarn-3.4.1.cjs nodeLinker: node-modules - -pnpMode: loose diff --git a/README.md b/README.md index 459a904..dfb9b57 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. @@ -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)})), }); ``` @@ -546,8 +547,16 @@ 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'), }); ``` @@ -555,7 +564,7 @@ createWrapper({ [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: @@ -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 }); ``` @@ -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 diff --git a/packages/demo-saga-page/src/components/store.tsx b/packages/demo-saga-page/src/components/store.tsx index 06f2dc2..c421820 100644 --- a/packages/demo-saga-page/src/components/store.tsx +++ b/packages/demo-saga-page/src/components/store.tsx @@ -29,6 +29,5 @@ const makeStore = ({reduxWrapperMiddleware}) => { const filterActions = ['@@redux-saga/CHANNEL_END', SAGA_ACTION]; export const wrapper = createWrapper(makeStore as any, { - debug: true, - actionFilter: action => !filterActions.includes(action.type), + serialize: actions => actions.filter(action => !filterActions.includes(action.type)), }); diff --git a/packages/demo-saga/src/components/store.tsx b/packages/demo-saga/src/components/store.tsx index 3aada3f..7c62dd6 100644 --- a/packages/demo-saga/src/components/store.tsx +++ b/packages/demo-saga/src/components/store.tsx @@ -29,6 +29,5 @@ export const makeStore = ({reduxWrapperMiddleware}) => { const filterActions = ['@@redux-saga/CHANNEL_END', SAGA_ACTION]; export const wrapper = createWrapper(makeStore as any, { - debug: true, - actionFilter: action => !filterActions.includes(action.type), + serialize: actions => actions.filter(action => !filterActions.includes(action.type)), }); diff --git a/packages/wrapper/src/index.tsx b/packages/wrapper/src/index.tsx index e2e8240..de6cc84 100644 --- a/packages/wrapper/src/index.tsx +++ b/packages/wrapper/src/index.tsx @@ -32,15 +32,9 @@ const REQPROP = '__NEXT_REDUX_WRAPPER_STORE__'; const getIsServer = () => typeof window === 'undefined'; -const getDeserializedAction = (action: any, {deserializeAction}: Config = {}) => - deserializeAction ? deserializeAction(action) : action; +const getDeserialized = (actions: Action[], {deserialize}: Config = {}): Action[] => (deserialize ? deserialize(actions) : actions); -const getSerializedAction = (action: any, {serializeAction}: Config = {}) => (serializeAction ? serializeAction(action) : action); - -const getActionFilter = - ({actionFilter}: Config = {}) => - (action: Action) => - actionFilter ? actionFilter(action) : true; +const getSerialized = (actions: Action[], {serialize}: Config = {}): Action[] => (serialize ? serialize(actions) : actions); export declare type MakeStore = (init: {context: Context; reduxWrapperMiddleware: Middleware}) => S; @@ -69,9 +63,9 @@ const createMiddleware = (config: Config): {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); }; @@ -175,13 +169,12 @@ const getStates = ({ const dispatchStates = (dispatch: Dispatch, states: PageProps, config: Config) => 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 = (makeStore: MakeStore, config: Config = {}) => { @@ -198,10 +191,10 @@ export const createWrapper = (makeStore: MakeStore, 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 @@ -388,10 +381,9 @@ export const createWrapper = (makeStore: MakeStore, config: export type Context = NextPageContext | AppContext | GetStaticPropsContext | GetServerSidePropsContext; export interface Config { - serializeAction?: (state: ReturnType) => any; - deserializeAction?: (state: any) => ReturnType; + serialize?: (actions: Action[]) => Action[]; + deserialize?: (actions: Action[]) => Action[]; debug?: boolean; - actionFilter?: (action: Action) => boolean | any; } export interface PageProps { diff --git a/packages/wrapper/tests/client.spec.tsx b/packages/wrapper/tests/client.spec.tsx index 13b6a37..904ed93 100644 --- a/packages/wrapper/tests/client.spec.tsx +++ b/packages/wrapper/tests/client.spec.tsx @@ -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, }); @@ -64,7 +64,7 @@ describe('client integration', () => { }); // expected when invoked above - await withStore(wrapper)(Page)?.getInitialProps({ + await Page.getInitialProps?.({ req: {}, res: {}, query: true, diff --git a/packages/wrapper/tests/server.spec.tsx b/packages/wrapper/tests/server.spec.tsx index 6a9f8df..95133f4 100644 --- a/packages/wrapper/tests/server.spec.tsx +++ b/packages/wrapper/tests/server.spec.tsx @@ -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({ @@ -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: { @@ -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: { @@ -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: { @@ -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, @@ -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', @@ -337,10 +337,17 @@ 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 => () => { @@ -348,7 +355,9 @@ describe('custom serialization', () => { 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, @@ -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()).toEqual( - JSON.stringify({ - props: { - ...pageProps, - reduxWrapperActionsGIPP: [action, actionAPP], - wrapper: {}, - }, - state: {reduxStatus: action.payload}, - }), - ); + expect(deserialize).toBeCalled(); }); }); diff --git a/packages/wrapper/tests/testlib.tsx b/packages/wrapper/tests/testlib.tsx index ebb50a5..2c0587b 100644 --- a/packages/wrapper/tests/testlib.tsx +++ b/packages/wrapper/tests/testlib.tsx @@ -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; };