diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7e7ef1c..ac40936 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -13,17 +13,16 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - - uses: actions/setup-node@v1 + - uses: actions/setup-node@v3 with: node-version: '16.x' - registry-url: 'https://registry.npmjs.org' - id: yarn-cache-dir-path - run: echo "::set-output name=dir::$(yarn cache dir)" + run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT - - uses: actions/cache@v2 + - uses: actions/cache@v3 with: path: | ${{ steps.yarn-cache-dir-path.outputs.dir }} @@ -42,6 +41,15 @@ jobs: env: CI: true + # FIXME Coverage report + + - uses: actions/upload-artifact@v3 + if: failure() + with: + path: | + packages/*/test-results + packages/*/coverage + - run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV - run: yarn publish:release ${TAG} --yes diff --git a/.gitignore b/.gitignore index 319b49e..a93e58e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ lib node_modules npm-debug* out +test-results types lerna-debug.log diff --git a/.lintstagedrc b/.lintstagedrc index 270de99..c6a9a57 100644 --- a/.lintstagedrc +++ b/.lintstagedrc @@ -1,4 +1,4 @@ { - "*.{js,jsx,ts,tsx}": ["yarn eslint", "git add"], + "*.{js,jsx,ts,tsx}": ["yarn lint", "git add"], "*.{js,jsx,ts,tsx,css,scss,sass,less,md,yml,json}": ["yarn prettier", "git add"] } \ No newline at end of file diff --git a/README.md b/README.md index a53c1ae..3788170 100644 --- a/README.md +++ b/README.md @@ -4,22 +4,26 @@ ![Build status](https://travis-ci.org/kirill-konshin/next-redux-wrapper.svg?branch=master) [![Coverage Status](https://coveralls.io/repos/github/kirill-konshin/next-redux-wrapper/badge.svg?branch=master)](https://coveralls.io/github/kirill-konshin/next-redux-wrapper?branch=master) -A HOC that brings Next.js and Redux together +A library that brings Next.js and Redux together. -:warning: The current version of this library only works with Next.js 9.3 and newer. If you are required to use Next.js 6-9 you can use version 3-5 of this library, see [branches](https://github.com/kirill-konshin/next-redux-wrapper/branches). Otherwise, consider upgrading Next.js. :warning: - -Contents: +> :warning: This library does not support the new experimental `app` folder because at this moment there is no clear way to use Redux with Server Components... +> +> Components that use Redux need a `Provider` up in the tree in order to work, and `Provider` is using `Context`, which is [not available on server](https://shopify.dev/custom-storefronts/hydrogen/react-server-components/work-with-rsc#using-context-in-react-server-components) (yet?). +> +> With that said, if you are using `app` folder, I suggest to keep Redux in client components (`"use client"`), and keep server-side state outside of Redux. - [Motivation](#motivation) - [Installation](#installation) - [Usage](#usage) - - [State reconciliation during hydration](#state-reconciliation-during-hydration) - - [Configuration](#configuration) + - [Store](#step-1-create-a-store) + - [App](#step-2-add-store-to-your-app) + - [Pages](#step-3-add-hydration-to-pages) - [getStaticProps](#getstaticprops) - [getServerSideProps](#getserversideprops) - [Page.getInitialProps](#pagegetinitialprops) - - [App](#app) - - [App and getServerSideProps or getStaticProps at page level](#app-and-getserversideprops-or-getstaticprops-at-page-level) + - [App.getInitialProps](#appgetinitialprops) +- [State reconciliation during hydration](#state-reconciliation-during-hydration) +- [Configuration](#configuration) - [How it works](#how-it-works) - [Tips and Tricks](#tips-and-tricks) - [Redux Toolkit](#redux-toolkit) @@ -30,6 +34,8 @@ Contents: - [Custom serialization and deserialization](#custom-serialization-and-deserialization) - [Usage with Redux Saga](#usage-with-redux-saga) - [Usage with Redux Persist](#usage-with-redux-persist) + - [Usage with Old Class Based Components](#usage-with-old-class-based-components) +- [Upgrade from 8.x to 9.x](#upgrade-from-8x-to-9x) - [Upgrade from 6.x to 7.x](#upgrade-from-6x-to-7x) - [Upgrade from 5.x to 6.x](#upgrade-from-5x-to-6x) - [Upgrade from 1.x to 2.x](#upgrade-from-1x-to-2x) @@ -37,17 +43,17 @@ Contents: # Motivation -Setting up Redux for static apps is rather simple: a single Redux store has to be created that is provided to all pages. +Setting up Redux for static single page apps is rather simple: a single Redux store has to be created that is provided to all pages. When Next.js static site generator or server side rendering is involved, however, things start to get complicated as another store instance is needed on the server to render Redux-connected components. -Furthermore, access to the Redux `Store` may also be needed during a page's `getInitialProps`. +Furthermore, access to the Redux `Store` may also be needed during a page's `getInitialProps`, and proper handling of complex cases like `App.getInitialProps` (when using `pages/_app`) together with `getStaticProps` or `getServerSideProps` at individual page level. -This is where `next-redux-wrapper` comes in handy: It automatically creates the store instances for you and makes sure they all have the same state. +This is where `next-redux-wrapper` comes in handy: it automatically creates the store instances for you and makes sure they all have the same state. -Moreover it allows to properly handle complex cases like `App.getInitialProps` (when using `pages/_app`) together with `getStaticProps` or `getServerSideProps` at individual page level. +Library provides uniform interface no matter in which Next.js data lifecycle method you would like to use the `Store`. -Library provides uniform interface no matter in which Next.js lifecycle method you would like to use the `Store`. +The hydration is performed by replaying the actions dispatched on server, in the same order. In Next.js example https://github.com/vercel/next.js/blob/canary/examples/with-redux-thunk/store.js#L23 store is being replaced on navigation. Redux will re-render components even with memoized selectors (`createSelector` from `recompose`) if `store` is replaced: https://codesandbox.io/s/redux-store-change-kzs8q, which may affect performance of the app by causing a huge re-render of everything, even what did not change. This library makes sure `store` remains the same. @@ -63,19 +69,19 @@ Note that `next-redux-wrapper` requires `react-redux` as peer dependency. Live example: https://codesandbox.io/s/next-redux-wrapper-demo-7n2t5. -All examples are written in TypeScript. If you're using plain JavaScript just omit type declarations. These examples use vanilla Redux, if you're using Redux Toolkit, please refer to [dedicated example](#redux-toolkit). +All examples are written in TypeScript. If you're using plain JavaScript just omit type declarations. These examples use vanilla Redux, if you're using Redux Toolkit, please refer to [dedicated example](#redux-toolkit), the general setup is the same. Next.js has several data fetching mechanisms, this library can attach to any of them. But first you have to write some common code. -**Please note that your reducer _must_ have the `HYDRATE` action handler. `HYDRATE` action handler must properly reconciliate the hydrated state on top of the existing state (if any).** This behavior was added in version 6 of this library. We'll talk about this special action later. +## Step 1. Create a store Create a file named `store.ts`: ```typescript // store.ts -import {createStore, AnyAction, Store} from 'redux'; -import {createWrapper, Context, HYDRATE} from 'next-redux-wrapper'; +import {createStore, applyMiddleware, AnyAction, Store} from 'redux'; +import {createWrapper, MakeStore} from 'next-redux-wrapper'; export interface State { tick: string; @@ -84,9 +90,6 @@ export interface State { // create your reducer const reducer = (state: State = {tick: 'init'}, action: AnyAction) => { switch (action.type) { - case HYDRATE: - // Attention! This will overwrite client state! Real apps should use proper reconciliation. - return {...state, ...action.payload}; case 'TICK': return {...state, tick: action.payload}; default: @@ -95,7 +98,8 @@ const reducer = (state: State = {tick: 'init'}, action: AnyAction) => { }; // create a makeStore function -const makeStore = (context: Context) => createStore(reducer); +const makeStore: MakeStore> = ({context, reduxWrapperMiddleware}) => + createStore(reducer, applyMiddleware(reduxWrapperMiddleware)); // make sure reduxWrapperMiddleware is last, after thunk or promise middlewares // export an assembled wrapper export const wrapper = createWrapper>(makeStore, {debug: true}); @@ -107,14 +111,12 @@ export const wrapper = createWrapper>(makeStore, {debug: true}); ```js // store.js -import {createStore} from 'redux'; -import {createWrapper, HYDRATE} from 'next-redux-wrapper'; +import {createStore, applyMiddleware} from 'redux'; +import {createWrapper} from 'next-redux-wrapper'; // create your reducer const reducer = (state = {tick: 'init'}, action) => { switch (action.type) { - case HYDRATE: - return {...state, ...action.payload}; case 'TICK': return {...state, tick: action.payload}; default: @@ -123,7 +125,7 @@ const reducer = (state = {tick: 'init'}, action) => { }; // create a makeStore function -const makeStore = context => createStore(reducer); +const makeStore = ({context, reduxWrapperMiddleware}) => createStore(reducer, applyMiddleware(reduxWrapperMiddleware)); // make sure reduxWrapperMiddleware is last, after thunk or promise middlewares // export an assembled wrapper export const wrapper = createWrapper(makeStore, {debug: true}); @@ -131,9 +133,9 @@ export const wrapper = createWrapper(makeStore, {debug: true}); -## `wrapper.useWrappedStore` +## Step 2. Add store to your App -It is highly recommended to use `pages/_app` to wrap all pages at once, otherwise due to potential race conditions you may get `Cannot update component while rendering another component`: +Use `pages/_app` to wrap all pages: ```tsx import React, {FC} from 'react'; @@ -141,125 +143,31 @@ import {Provider} from 'react-redux'; import {AppProps} from 'next/app'; import {wrapper} from '../components/store'; -const MyApp: FC = ({Component, ...rest}) => { - const {store, props} = wrapper.useWrappedStore(rest); +const MyApp: FC = function MyApp({Component, pageProps}) { + const store = wrapper.useStore(); return ( - + ); }; ``` -Instead of `wrapper.useWrappedStore` you can also use legacy HOC, that can work with class-based components. +## Step 3. Add hydration to Pages -:warning: Next.js provides [generic `getInitialProps`](https://github.com/vercel/next.js/blob/canary/packages/next/pages/_app.tsx#L21) when using `class MyApp extends App` which will be picked up by wrapper, so you **must not extend `App`** as you'll be opted out of Automatic Static Optimization: https://err.sh/next.js/opt-out-auto-static-optimization. Just export a regular Functional Component as in the example above. +**Each page has to call `wrapper.useHydration(props)` in order to perform hydration.** If page won't use `wrapper.useHydration` — this page will not be hydrated, even if it has `getServerSideProps` or other data functions. ```tsx import React from 'react'; -import {wrapper} from '../components/store'; -import {AppProps} from 'next/app'; - -class MyApp extends React.Component { - render() { - const {Component, pageProps} = this.props; - return ; - } -} - -export default wrapper.withRedux(MyApp); -``` - -## State reconciliation during hydration - -Each time when pages that have `getStaticProps` or `getServerSideProps` are opened by user the `HYDRATE` action will be dispatched. This may happen during initial page load and during regular page navigation. The `payload` of this action will contain the `state` at the moment of static generation or server side rendering, so your reducer must merge it with existing client state properly. - -Simplest way is to use [server and client state separation](#server-and-client-state-separation). - -Another way is to use https://github.com/benjamine/jsondiffpatch to analyze diff and apply it properly: - -```tsx -import {HYDRATE} from 'next-redux-wrapper'; - -// create your reducer -const reducer = (state = {tick: 'init'}, action) => { - switch (action.type) { - case HYDRATE: - const stateDiff = diff(state, action.payload) as any; - const wasBumpedOnClient = stateDiff?.page?.[0]?.endsWith('X'); // or any other criteria - return { - ...state, - ...action.payload, - page: wasBumpedOnClient ? state.page : action.payload.page, // keep existing state or use hydrated - }; - case 'TICK': - return {...state, tick: action.payload}; - default: - return state; - } -}; -``` - -Or [like this](https://github.com/zeit/next.js/blob/canary/examples/with-redux-wrapper/store/store.js) (from [with-redux-wrapper example](https://github.com/zeit/next.js/tree/canary/examples/with-redux-wrapper) in Next.js repo): - -```js -const reducer = (state, action) => { - if (action.type === HYDRATE) { - const nextState = { - ...state, // use previous state - ...action.payload, // apply delta from hydration - }; - if (state.count) nextState.count = state.count; // preserve count value on client side navigation - return nextState; - } else { - return combinedReducer(state, action); - } -}; -``` - -## Configuration - -The `createWrapper` function accepts `makeStore` as its first argument. The `makeStore` function should return a new Redux `Store` instance each time it's called. No memoization is needed here, it is automatically done inside the wrapper. - -`createWrapper` also optionally accepts a config object as a second parameter: - -- `debug` (optional, boolean) : enable debug logging -- `serializeState` and `deserializeState`: custom functions for serializing and deserializing the redux state, 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. - -Some of those contexts (`getServerSideProps` always, and `NextPageContext`, `AppContext` sometimes if page is rendered on server) can have request and response related properties: - -- `req` (`IncomingMessage`) -- `res` (`ServerResponse`) - -Although it is possible to create server or client specific logic in both `makeStore`, I highly recommend that they do not have different behavior. This may cause errors and checksum mismatches which in turn will ruin the whole purpose of server rendering. - -## getStaticProps - -This section describes how to attach to [getStaticProps](https://nextjs.org/docs/basic-features/data-fetching#getstaticprops-static-generation) lifecycle function. - -Let's create a page in `pages/pageName.tsx`: - -```typescript -import React from 'react'; import {NextPage} from 'next'; import {useSelector} from 'react-redux'; -import {wrapper, State} from '../store'; +import {wrapper, State, getSomeValue} from '../store'; -export const getStaticProps = wrapper.getStaticProps(store => ({preview}) => { - console.log('2. Page.getStaticProps uses the store to dispatch things'); - store.dispatch({ - type: 'TICK', - payload: 'was set in other page ' + preview, - }); -}); - -// you can also use `connect()` instead of hooks -const Page: NextPage = () => { - const {tick} = useSelector(state => state); - return
{tick}
; +const Page: NextPage = props => { + const {hydrating} = wrapper.useHydration(props); // dump all props to hook + const {someValue} = useSelector(getSomeValue); + if (hydrating) return
Loading...
; + return
{someValue}
; }; export default Page; @@ -269,22 +177,16 @@ export default Page; Same code in JavaScript (without types) ```js +// store.js import React from 'react'; import {useSelector} from 'react-redux'; -import {wrapper} from '../store'; +import {wrapper, State, getSomeValue} from '../store'; -export const getStaticProps = wrapper.getStaticProps(store => ({preview}) => { - console.log('2. Page.getStaticProps uses the store to dispatch things'); - store.dispatch({ - type: 'TICK', - payload: 'was set in other page ' + preview, - }); -}); - -// you can also use `connect()` instead of hooks -const Page = () => { - const {tick} = useSelector(state => state); - return
{tick}
; +const Page = props => { + const {hydrating} = wrapper.useHydration(props); // dump all props to hook + const {someValue} = useSelector(getSomeValue); + if (hydrating) return
Loading...
; + return
{someValue}
; }; export default Page; @@ -292,315 +194,146 @@ export default Page; -:warning: **Each time when pages that have `getStaticProps` are opened by user the `HYDRATE` action will be dispatched.** The `payload` of this action will contain the `state` at the moment of static generation, it will not have client state, so your reducer must merge it with existing client state properly. More about this in [Server and Client State Separation](#server-and-client-state-separation). +:warning: **Since hydration can happen both on first visit and on subsequent navigation (then hydration will be asynchronous) `getSomeValue` selector has to safely handle empty store state. Component will be rendered twice, with empty state, and after hydration. Write selectors like so `export const getSomeValue = createSelector(getAnotherValue, s => s?.someValue);`.**. -Although you can wrap individual pages (and not wrap the `pages/_app`) it is not recommended, see last paragraph in [usage section](#usage). +You can use `hydrating` variable to understand the status of the hydration and show loading screen if needed. -## getServerSideProps +The `wrapper.useHydration` hook needs access to special props supplied to component: `initialStateGSSP`, `initialStateGSP`, `initialStateGIAP`, `initialStateGIPP`. You can destructure `props` to pull out those you use directly, just make sure to provide special ones to the hook: -This section describes how to attach to [getServerSideProps](https://nextjs.org/docs/basic-features/data-fetching#getserversideprops-server-side-rendering) lifecycle function. +```js +const Page = ({foo, bar, ...props}) => { + wrapper.useHydration(props); + // ... rest of code +}; +``` -Let's create a page in `pages/pageName.tsx`: +If you have lots of legacy selectors that assume store is pre-hydrated before render, you can use approach for [usage with old class-based components](#usage-with-old-class-based-components): `withHydration` HOC to delay rendering until store is hydrated. In this case make sure such selectors are not used anywhere except on the wrapped page. -```typescript -import React from 'react'; -import {NextPage} from 'next'; -import {connect} from 'react-redux'; -import {wrapper, State} from '../store'; +### getStaticProps -export const getServerSideProps = wrapper.getServerSideProps(store => ({req, res, ...etc}) => { - console.log('2. Page.getServerSideProps uses the store to dispatch things'); - store.dispatch({type: 'TICK', payload: 'was set in other page'}); -}); - -// Page itself is not connected to Redux Store, it has to render Provider to allow child components to connect to Redux Store -const Page: NextPage = ({tick}) =>
{tick}
; - -// you can also use Redux `useSelector` and other hooks instead of `connect()` -export default connect((state: State) => state)(Page); -``` +This section describes how to attach to [getStaticProps](https://nextjs.org/docs/basic-features/data-fetching#getstaticprops-static-generation) lifecycle function. -
-Same code in JavaScript (without types) +Let's create a page in `pages/pageName.tsx`: ```js -import React from 'react'; -import {connect} from 'react-redux'; import {wrapper} from '../store'; -export const getServerSideProps = wrapper.getServerSideProps(store => ({req, res, ...etc}) => { - console.log('2. Page.getServerSideProps uses the store to dispatch things'); - store.dispatch({type: 'TICK', payload: 'was set in other page'}); +export const getStaticProps = wrapper.getStaticProps(store => ({preview}) => { + store.dispatch({type: 'TICK', payload: 'was set in other page ' + preview}); + return {props: {foo: 'bar'}}; // your ususal props }); -// Page itself is not connected to Redux Store, it has to render Provider to allow child components to connect to Redux Store -const Page = ({tick}) =>
{tick}
; - -// you can also use Redux `useSelector` and other hooks instead of `connect()` -export default connect(state => state)(Page); +// ... usual Page component code ``` -
+### getServerSideProps -:warning: **Each time when pages that have `getServerSideProps` are opened by user the `HYDRATE` action will be dispatched.** The `payload` of this action will contain the `state` at the moment of server side rendering, it will not have client state, so your reducer must merge it with existing client state properly. More about this in [Server and Client State Separation](#server-and-client-state-separation). - -Although you can wrap individual pages (and not wrap the `pages/_app`) it is not recommended, see last paragraph in [usage section](#usage). - -## `Page.getInitialProps` +This section describes how to attach to [getServerSideProps](https://nextjs.org/docs/basic-features/data-fetching#getserversideprops-server-side-rendering) lifecycle function. -```typescript -import React, {Component} from 'react'; -import {NextPage} from 'next'; -import {wrapper, State} from '../store'; +Let's create a page in `pages/pageName.tsx`: -// you can also use `connect()` instead of hooks -const Page: NextPage = () => { - const {tick} = useSelector(state => state); - return
{tick}
; -}; +```js +import {wrapper} from '../store'; -Page.getInitialProps = wrapper.getInitialPageProps(store => ({pathname, req, res}) => { - console.log('2. Page.getInitialProps uses the store to dispatch things'); - store.dispatch({ - type: 'TICK', - payload: 'was set in error page ' + pathname, - }); +export const getServerSideProps = wrapper.getServerSideProps(store => ({req, res, ...etc}) => { + store.dispatch({type: 'TICK', payload: 'was set in other page'}); + return {props: {foo: 'bar'}}; // your ususal props }); -export default Page; +// ... usual Page component code ``` -
-Same code in JavaScript (without types) +### `Page.getInitialProps` ```js -import React, {Component} from 'react'; import {wrapper} from '../store'; -// you can also use `connect()` instead of hooks -const Page = () => { - const {tick} = useSelector(state => state); - return
{tick}
; -}; +// ... usual Page component code Page.getInitialProps = wrapper.getInitialPageProps(store => ({pathname, req, res}) => { - console.log('2. Page.getInitialProps uses the store to dispatch things'); - store.dispatch({ - type: 'TICK', - payload: 'was set in error page ' + pathname, - }); + store.dispatch({type: 'TICK', payload: 'was set in error page ' + pathname}); + return {foo: 'bar'}; // your ususal props }); - -export default Page; -``` - -
- -Keep in mind that `req` and `res` may not be available if `getInitialProps` is called on client side. - -Stateless function component also can be replaced with class: - -```js -class Page extends Component { - - public static getInitialProps = wrapper.getInitialPageProps(store => () => ({ ... })); - - render() { - // stuff - } -} - -export default Page; ``` -Although you can wrap individual pages (and not wrap the `pages/_app`) it is not recommended, see last paragraph in [usage section](#usage). - -## App - -:warning: You can dispatch actions from the `pages/_app` too. But this mode is not compatible with [Next.js 9's Auto Partial Static Export](https://nextjs.org/blog/next-9#automatic-partial-static-export) feature, see the [explanation below](#automatic-partial-static-export). - -The wrapper can also be attached to your `_app` component (located in `/pages`). All other components can use the `connect` function of `react-redux`. - -```tsx -// pages/_app.tsx - -import React from 'react'; -import App, {AppInitialProps} from 'next/app'; -import {wrapper} from '../components/store'; -import {State} from '../components/reducer'; - -// Since you'll be passing more stuff to Page -declare module 'next/dist/next-server/lib/utils' { - export interface NextPageContext { - store: Store; - } -} - -class MyApp extends App { - public static getInitialProps = wrapper.getInitialAppProps(store => async context => { - store.dispatch({type: 'TOE', payload: 'was set in _app'}); - - return { - pageProps: { - // https://nextjs.org/docs/advanced-features/custom-app#caveats - ...(await App.getInitialProps(context)).pageProps, - // Some custom thing for all pages - pathname: ctx.pathname, - }, - }; - }); +:warning: `req` and `res` are not available if `getInitialProps` is called on client side during navigation. - public render() { - const {Component, pageProps} = this.props; - - return ; - } -} +### `App.getInitialProps` -export default wrapper.withRedux(MyApp); -``` +:warning: Not recommended! :warning: -
-Same code in JavaScript (without types) +You can dispatch actions from the `pages/_app` too. This mode is not compatible with [Next.js 9's Auto Partial Static Export](https://nextjs.org/blog/next-9#automatic-partial-static-export) feature, see the [explanation below](#automatic-partial-static-export). -```jsx +```js // pages/_app.tsx import React from 'react'; import App from 'next/app'; import {wrapper} from '../components/store'; -class MyApp extends App { - static getInitialProps = wrapper.getInitialAppProps(store => async context => { - store.dispatch({type: 'TOE', payload: 'was set in _app'}); - - return { - pageProps: { - // https://nextjs.org/docs/advanced-features/custom-app#caveats - ...(await App.getInitialProps(context)).pageProps, - // Some custom thing for all pages - pathname: ctx.pathname, - }, - }; - }); +// ... usual MyApp code - render() { - const {Component, pageProps} = this.props; +MyApp.getInitialProps = wrapper.getInitialAppProps(store => async context => { + store.dispatch({type: 'TOE', payload: 'was set in _app'}); - return ; - } -} - -export default wrapper.withRedux(MyApp); -``` - -
- -Then all pages can simply be connected (the example considers page components): - -```tsx -// pages/xxx.tsx - -import React from 'react'; -import {NextPage} from 'next'; -import {connect} from 'react-redux'; -import {NextPageContext} from 'next'; -import {State} from '../store'; - -const Page: NextPage = ({foo, custom}) => ( -
-
Prop from Redux {foo}
-
Prop from getInitialProps {custom}
-
-); - -// No need to wrap pages if App was wrapped -Page.getInitialProps = ({store, pathname, query}: NextPageContext) => { - store.dispatch({type: 'FOO', payload: 'foo'}); // The component can read from the store's state when rendered - return {custom: 'custom'}; // You can pass some custom props to the component from here -}; + return { + pageProps: { + // https://nextjs.org/docs/advanced-features/custom-app#caveats + ...(await App.getInitialProps(context)).pageProps, + // Some custom thing for all pages + pathname: ctx.pathname, + }, + }; +}); -export default connect((state: State) => state)(Page); +export default MyApp; ``` -
-Same code in JavaScript (without types) - -```jsx -// pages/xxx.js +:warning: `req` and `res` are not available if `App.getInitialProps` or `Page.getInitialProps` are called on client side during navigation. And the actions dispatched from `App.getInitialProps` or `Page.getInitialProps` will be dispatched on client side. -import React from 'react'; -import {connect} from 'react-redux'; +All pages still can have all standard data lifecycle methods, with one common pitfall: -const Page = ({foo, custom}) => ( -
-
Prop from Redux {foo}
-
Prop from getInitialProps {custom}
-
-); +:warning: You can use `getStaticProps` at page level while having `App.getInitialProps`, this scenario is supported, but I highly don't recommend to do this. -// No need to wrap pages if App was wrapped -Page.getInitialProps = ({store, pathname, query}) => { - store.dispatch({type: 'FOO', payload: 'foo'}); // The component can read from the store's state when rendered - return {custom: 'custom'}; // You can pass some custom props to the component from here -}; +# State reconciliation during hydration -export default connect(state => state)(Page); -``` +Each time when the user opens a page containing the `useHydration` hook, the actions performed on server will be dispatched on client as well. This may happen during initial page load and during regular page navigation. Your reducer must merge it with existing client state properly. -
+Best way is to use [server and client state separation](#server-and-client-state-separation). -## App and `getServerSideProps` or `getStaticProps` at page level +Another way is to use https://github.com/benjamine/jsondiffpatch to analyze diff and apply it properly, or any other way to determine which state subtrees were modified. -You can also use `getServerSideProps` or `getStaticProps` at page level, in this case `HYDRATE` action will be dispatched twice: with state after `App.getInitialProps` and then with state after `getServerSideProps` or `getStaticProps`: - -- If you use `getServerSideProps` at page level then `store` in `getServerSideProps` will be executed after `App.getInitialProps` and will have state from it, so second `HYDRATE` will have full state from both -- :warning: If you use `getStaticProps` at page level then `store` in `getStaticProps` will be executed at compile time and will **NOT** have state from `App.getInitialProps` because they are executed in different contexts and state cannot be shared. First `HYDRATE` actions state after `App.getInitialProps` and second will have state after `getStaticProps` (even though it was executed earlier in time). - -Simplest way to ensure proper merging is to drop initial values from `action.payload`: - -```typescript -const reducer = (state: State = {app: 'init', page: 'init'}, action: AnyAction) => { +```js +// create your reducer +const reducer = (state = {tick: 'init'}, action) => { switch (action.type) { - case HYDRATE: - if (action.payload.app === 'init') delete action.payload.app; - if (action.payload.page === 'init') delete action.payload.page; - return {...state, ...action.payload}; - case 'APP': - return {...state, app: action.payload}; - case 'PAGE': - return {...state, page: action.payload}; + case 'TICK': + const wasBumpedOnClient = state.tick !== 'init'; // or any other criteria + return {...state, tick: wasBumpedOnClient ? state.tick : action.payload}; default: return state; } }; ``` -
-Same code in JavaScript (without types) +# Configuration -```js -const reducer = (state = {app: 'init', page: 'init'}, action) => { - switch (action.type) { - case HYDRATE: - if (action.payload.app === 'init') delete action.payload.app; - if (action.payload.page === 'init') delete action.payload.page; - return {...state, ...action.payload}; - case 'APP': - return {...state, app: action.payload}; - case 'PAGE': - return {...state, page: action.payload}; - default: - return state; - } -}; -``` +The `createWrapper` function accepts `makeStore` as its first argument. The `makeStore` function should return a new Redux `Store` instance each time it's called, **no memoization is needed here**, it is automatically done inside the wrapper. -
+`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 -Assume page only dispatches `PAGE` action and App only `APP`, this makes state merging safe. +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. + +Some of those contexts (`getServerSideProps` always, and `NextPageContext`, `AppContext` sometimes if page is rendered on server) can have request and response related properties: + +- `req` (`IncomingMessage`) +- `res` (`ServerResponse`) -More about that in [Server and Client state separation](#server-and-client-state-separation). +Although it is possible to create server or client specific logic in both `makeStore`, I highly recommend that they do not have different behavior. This may cause errors and checksum mismatches which in turn will ruin the whole purpose of server rendering. # How it works @@ -608,43 +341,42 @@ Using `next-redux-wrapper` ("the wrapper"), the following things happen on a req - Phase 1: `getInitialProps`/`getStaticProps`/`getServerSideProps` - - The wrapper creates a server-side store (using `makeStore`) with an empty initial state. In doing so it also provides the `Request` and `Response` objects as options to `makeStore`. - - In App mode: - - The wrapper calls the `_app`'s `getInitialProps` function and passes the previously created store. - - Next.js takes the props returned from the `_app`'s `getInitialProps` method, along with the store's state. - - In per-page mode: - - The wrapper calls the Page's `getXXXProps` function and passes the previously created store. - - Next.js takes the props returned from the Page's `getXXXProps` method, along with the store's state. + - The wrapper creates a server-side store (using `makeStore`) with an empty initial state. + - The wrapper calls the `_app`'s `getInitialProps` (if any) function and passes the previously created store. + - Next.js takes the props returned from the `_app`'s `getInitialProps` method, as well as a serialized list of actions that were dispatched. + - The wrapper calls the Page's `getXXXProps` function and passes the previously created store. + - Next.js takes the props returned from the Page's `getXXXProps` method, as well as a serialized list of actions that were dispatched. - Phase 2: SSR - The wrapper creates a new store using `makeStore` - - The wrapper dispatches `HYDRATE` action with the previous store's state as `payload` - - That store is passed as a property to the `_app` or `page` component. - - **Connected components may alter the store's state, but the modified state will not be transferred to the client.** + - The wrapper replays all actions dispatched on Phase 1 + - Resulting HTML is emitted to browser + - **Connected components may dispatch actions in this phase too, but they will not be replayed on the client.** - Phase 3: Client + - The wrapper creates a new store - - The wrapper dispatches `HYDRATE` action with the state from Phase 1 as `payload` - - That store is passed as a property to the `_app` or `page` component. - - The wrapper persists the store in the client's window object, so it can be restored in case of HMR. + - The wrapper replays all actions dispatched in Phase 1 -**Note:** The client's state is not persisted across requests (i.e. Phase 1 always starts with an empty state). -Hence, it is reset on page reloads. -Consider using [Redux persist](#usage-with-redux-persist) if you want to persist state between requests. +- Phase 4: Soft client navigation (without reload) + - The wrapper reuses same client store + - The wrapper idles if no `getServerSideProps` or `getStaticProps` were used on the page, otherwise the wrapper replays all actions dispatched in Phase 1 of the new page + +**Note:** The client's state is not persisted across requests (i.e. Phase 1 always starts with an empty state). Hence, it is reset on page reloads. Consider using [Redux persist](#usage-with-redux-persist) if you want to persist state between page reloads or hard navigation. ## Tips and Tricks -### Redux Toolkit +## Redux Toolkit -Since version `7.0` first-class support of `@reduxjs/toolkit` has been added. +Wrapper has first-class support of `@reduxjs/toolkit`. Full example: https://github.com/kirill-konshin/next-redux-wrapper/blob/master/packages/demo-redux-toolkit. ```ts import {configureStore, createSlice, ThunkAction} from '@reduxjs/toolkit'; import {Action} from 'redux'; -import {createWrapper, HYDRATE} from 'next-redux-wrapper'; +import {createWrapper} from 'next-redux-wrapper'; export const subjectSlice = createSlice({ name: 'subject', @@ -656,24 +388,15 @@ export const subjectSlice = createSlice({ return action.payload; }, }, - - extraReducers: { - [HYDRATE]: (state, action) => { - console.log('HYDRATE', state, action.payload); - return { - ...state, - ...action.payload.subject, - }; - }, - }, }); -const makeStore = () => +const makeStore = ({reduxWrapperMiddleware}) => configureStore({ reducer: { [subjectSlice.name]: subjectSlice.reducer, }, devTools: true, + middleware: getDefaultMiddleware => getDefaultMiddleware().concat(reduxWrapperMiddleware), }); export type AppStore = ReturnType; @@ -710,68 +433,20 @@ export type AppState = ReturnType; export type AppThunk = ThunkAction; ``` -### Server and Client state separation +## Server and Client state separation -Each time when pages that have `getStaticProps` or `getServerSideProps` are opened by user the `HYDRATE` action will be dispatched. The `payload` of this action will contain the `state` at the moment of static generation or server side rendering, so your reducer must merge it with existing client state properly. +Each time when pages that have `getStaticProps` or `getServerSideProps` or `getStaticProps` are opened by user the `HYDRATE` action will be dispatched. The `payload` of this action will contain the `state` at the moment of static generation or server side rendering, so your reducer must merge it with existing client state properly. The easiest and most stable way to make sure nothing is accidentally overwritten is to make sure that your reducer applies client side and server side actions to different substates of your state and they never clash: -```typescript -export interface State { - server: any; - client: any; -} - -const reducer = (state: State = {tick: 'init'}, action: AnyAction) => { - switch (action.type) { - case HYDRATE: - return { - ...state, - server: { - ...state.server, - ...action.payload.server, - }, - }; - case 'SERVER_ACTION': - return { - ...state, - server: { - ...state.server, - tick: action.payload, - }, - }; - case 'CLIENT_ACTION': - return { - ...state, - client: { - ...state.client, - tick: action.payload, - }, - }; - default: - return state; - } -}; -``` - -
-Same code in JavaScript (without types) - ```js const reducer = (state = {tick: 'init'}, action) => { switch (action.type) { - case HYDRATE: - return { - ...state, - server: { - ...state.server, - ...action.payload.server, - }, - }; case 'SERVER_ACTION': return { ...state, server: { + // only change things in server subtree ...state.server, tick: action.payload, }, @@ -780,6 +455,7 @@ const reducer = (state = {tick: 'init'}, action) => { return { ...state, client: { + // only change things in client subtree ...state.client, tick: action.payload, }, @@ -790,20 +466,15 @@ const reducer = (state = {tick: 'init'}, action) => { }; ``` -
- -If you prefer an isomorphic approach for some (preferably small) portions of your state, you can share them between client and server on server-rendered pages using [next-redux-cookie-wrapper](https://github.com/bjoluc/next-redux-cookie-wrapper), an extension to next-redux-wrapper. -In this case, for selected substates, the server is aware of the client's state (unless in `getStaticProps`) and there is no need to separate server and client state. +If you prefer an isomorphic approach for some (preferably small) portions of your state, you can share them between client and server on server-rendered pages using [next-redux-cookie-wrapper](https://github.com/bjoluc/next-redux-cookie-wrapper), an extension to next-redux-wrapper. In this case, for selected substates, the server is aware of the client's state (unless in `getStaticProps`) and there is no need to separate server and client state. Also, you can use a library like https://github.com/benjamine/jsondiffpatch to analyze diff and apply it properly. -### Document +## Document -I don't recommend using `withRedux` in `pages/_document.js`, Next.JS [does not provide](https://github.com/zeit/next.js/issues/1267) -a reliable way to determine the sequence when components will be rendered. So per Next.JS recommendation it is better -to have just data-agnostic things in `pages/_document`. +Do not use this library in `pages/_document.js`, Next.JS [does not provide](https://github.com/zeit/next.js/issues/1267) a reliable way to determine the sequence when components will be rendered. So per Next.JS recommendation it is better to have just data-agnostic things in `pages/_document`. -### Error Pages +## Error Pages Error pages can also be wrapped the same way as any other pages. @@ -811,7 +482,7 @@ Transition to an error page (`pages/_error.js` template) will cause `pages/_app. full page transition (not HTML5 pushState), so client will have the store created from scratch using state from the server. So unless you persist the store on the client somehow the resulting previous client state will be ignored. -### Async actions +## Async actions You can use https://github.com/reduxjs/redux-thunk to dispatch async actions: @@ -845,11 +516,11 @@ function someAsyncAction() { await store.dispatch(someAsyncAction()); ``` -### Custom serialization and deserialization +## 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 `serializeState` and `deserializeState` as config options to `withRedux`. +provide `serializeAction` and `deserializeAction` as config options to `createStore`. The reason is that state snapshot is transferred over the network from server to client as a plain object. @@ -859,8 +530,8 @@ Example of a custom serialization of an Immutable.JS state using `json-immutable const {serialize, deserialize} = require('json-immutable'); createWrapper({ - serializeState: state => serialize(state), - deserializeState: state => deserialize(state), + serializeAction: action => ({...action, payload: serialize(action.payload)}), + deserializeAction: action => ({...action, payload: deserialize(action.payload)}), }); ``` @@ -870,61 +541,32 @@ Same thing using Immutable.JS: const {fromJS} = require('immutable'); createWrapper({ - serializeState: state => state.toJS(), - deserializeState: state => fromJS(state), + serializeAction: action => ({...action, payload: action.payload.toJS()}), + deserializeAction: action => ({...action, payload: fromJS(action)}), }); ``` -### Usage with Redux Saga +## 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. -Create your root saga as usual, then implement the store creator: +Don't forget to filter out actions that cause saga to run using `actionFilter` config property. -```typescript -import {createStore, applyMiddleware, Store} from 'redux'; -import {createWrapper, Context} from 'next-redux-wrapper'; -import createSagaMiddleware, {Task} from 'redux-saga'; -import reducer, {State} from './reducer'; -import rootSaga from './saga'; - -export interface SagaStore extends Store { - sagaTask?: Task; -} - -export const makeStore = (context: Context) => { - // 1: Create the middleware - const sagaMiddleware = createSagaMiddleware(); - - // 2: Add an extra parameter for applying middleware: - const store = createStore(reducer, applyMiddleware(sagaMiddleware)); - - // 3: Run your sagas on server - (store as SagaStore).sagaTask = sagaMiddleware.run(rootSaga); - - // 4: now return the store: - return store; -}; - -export const wrapper = createWrapper>(makeStore, {debug: true}); -``` - -
-Same code in JavaScript (without types) +Create your root saga as usual, then implement the store creator: ```js import {createStore, applyMiddleware} from 'redux'; import {createWrapper} from 'next-redux-wrapper'; import createSagaMiddleware from 'redux-saga'; import reducer from './reducer'; -import rootSaga from './saga'; +import rootSaga, {SAGA_ACTION} from './saga'; -export const makeStore = context => { +export const makeStore = ({context, reduxWrapperMiddleware}) => { // 1: Create the middleware const sagaMiddleware = createSagaMiddleware(); // 2: Add an extra parameter for applying middleware: - const store = createStore(reducer, applyMiddleware(sagaMiddleware)); + const store = createStore(reducer, applyMiddleware(sagaMiddleware, reduxWrapperMiddleware)); // 3: Run your sagas on server store.sagaTask = sagaMiddleware.run(rootSaga); @@ -933,50 +575,33 @@ export const makeStore = context => { return store; }; -export const wrapper = createWrapper(makeStore, {debug: true}); +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 +}); ``` -
- -#### Using `pages/_app` +### Using `getServerSideProps` or `getStaticProps` -Then in the `pages/_app` wait stop saga and wait for it to finish when execution is on server: +If you don't want to opt-out of automatic pre-rendering in your Next.js app, you can manage server-called sagas on a per page basis like [the official Next.js "with Redux Saga" example](https://github.com/vercel/next.js/tree/canary/examples/with-redux-saga) does. If you do go with this option, please ensure that you await any and all sagas within any [Next.js page methods](https://nextjs.org/docs/basic-features/data-fetching). If you miss it on one of pages you'll end up with inconsistent state being sent to client. -```tsx -import React from 'react'; -import App, {AppInitialProps} from 'next/app'; -import {END} from 'redux-saga'; -import {SagaStore, wrapper} from '../components/store'; - -class WrappedApp extends App { - public static getInitialProps = wrapper.getInitialAppProps(store => async context => { - // 1. Wait for all page actions to dispatch - const pageProps = { - // https://nextjs.org/docs/advanced-features/custom-app#caveats - ...(await App.getInitialProps(context)).pageProps, - }; - - // 2. Stop the saga if on server - if (context.ctx.req) { - store.dispatch(END); - await (store as SagaStore).sagaTask.toPromise(); - } +In order to use it with `getServerSideProps` or `getStaticProps` you need to `await` for sagas in each page's handler: - // 3. Return props - return {pageProps}; - }); +```js +export const getServerSideProps = wrapper.getServerSideProps(store => async ({req, res, ...etc}) => { + // regular stuff + store.dispatch(ApplicationSlice.actions.updateConfiguration()); + // end the saga + store.dispatch(END); + await store.sagaTask.toPromise(); +}); +``` - public render() { - const {Component, pageProps} = this.props; - return ; - } -} +### Using `App.getInitialProps` -export default wrapper.withRedux(WrappedApp); -``` +:warning: Not Recommended! :warning: -
-Same code in JavaScript (without types) +Then in the `pages/_app` wait stop saga and wait for it to finish when execution is on server: ```js import React from 'react'; @@ -984,54 +609,29 @@ import App from 'next/app'; import {END} from 'redux-saga'; import {SagaStore, wrapper} from '../components/store'; -class WrappedApp extends App { - static getInitialProps = wrapper.getInitialAppProps(store => async context => { - // 1. Wait for all page actions to dispatch - const pageProps = { - // https://nextjs.org/docs/advanced-features/custom-app#caveats - ...(await App.getInitialProps(context)).pageProps, - }; - - // 2. Stop the saga if on server - if (context.ctx.req) { - store.dispatch(END); - await store.sagaTask.toPromise(); - } - - // 3. Return props - return {pageProps}; - }); - - public render() { - const {Component, pageProps} = this.props; - return ; - } -} - -export default wrapper.withRedux(WrappedApp); -``` - -
+// ... usual MyApp code -#### Using `getServerSideProps` or `getStaticProps` +MyApp.getInitialProps = wrapper.getInitialAppProps(store => async context => { + // 1. Wait for all page actions to dispatch + const pageProps = { + // https://nextjs.org/docs/advanced-features/custom-app#caveats + ...(await App.getInitialProps(context)).pageProps, + }; -In order to use it with `getServerSideProps` or `getStaticProps` you need to `await` for sagas in each page's handler: + // 2. Stop the saga if on server + if (context.ctx.req) { + store.dispatch(END); + await store.sagaTask.toPromise(); + } -```js -export const getServerSideProps = ReduxWrapper.getServerSideProps(async ({store, req, res, ...etc}) => { - // regular stuff - store.dispatch(ApplicationSlice.actions.updateConfiguration()); - // end the saga - store.dispatch(END); - await store.sagaTask.toPromise(); + // 3. Return props + return {pageProps}; }); -``` - -#### Usage without `getInitialProps` inside `_app` -If you don't want to opt-out of automatic pre-rendering in your Next.js app, you can manage server-called sagas on a per page basis like [the official Next.js "with Redux Saga" example](https://github.com/vercel/next.js/tree/canary/examples/with-redux-saga) does. If you do go with this option, please ensure that you await any and all sagas within any [Next.js page methods](https://nextjs.org/docs/basic-features/data-fetching). If you miss it on one of pages you'll end up with inconsistent state being sent to client. So, we consider waiting in `_app` to be automatically safer, but obviously the main drawback is opting out of automatic static exports. +export default MyApp; +``` -### Usage with Redux Persist +## Usage with Redux Persist > If you only need to persist small portions of your state, [next-redux-cookie-wrapper](https://github.com/bjoluc/next-redux-cookie-wrapper) might be an easy alternative to Redux Persist that supports SSR. @@ -1061,11 +661,11 @@ export const reducer = (state, {type, payload}) => { return state; }; -const makeConfiguredStore = reducer => createStore(reducer, undefined, applyMiddleware(logger)); - -const makeStore = () => { +const makeStore = ({context, reduxWrapperMiddleware}) => { const isServer = typeof window === 'undefined'; + const makeConfiguredStore = reducer => createStore(reducer, undefined, applyMiddleware(logger, reduxWrapperMiddleware)); + if (isServer) { return makeConfiguredStore(reducer); } else { @@ -1096,35 +696,7 @@ export const setClientState = clientState => ({ }); ``` -And then in Next.js `_app` page you can use bare context access to get the store (https://react-redux.js.org/api/provider#props): - -```js -// pages/_app.tsx -import React from 'react'; -import App from 'next/app'; -import {ReactReduxContext} from 'react-redux'; -import {wrapper} from './lib/redux'; -import {PersistGate} from 'redux-persist/integration/react'; - -export default wrapper.withRedux( - class MyApp extends App { - render() { - const {Component, pageProps} = this.props; - return ( - - {({store}) => ( - Loading}> - - - )} - - ); - } - }, -); -``` - -Or using hooks: +Then add store & persistor to `_app`: ```js // pages/_app.tsx @@ -1134,14 +706,25 @@ import {useStore} from 'react-redux'; import {wrapper} from './lib/redux'; import {PersistGate} from 'redux-persist/integration/react'; -export default wrapper.withRedux(({Component, pageProps}) => { +const Persistor = ({Component, pageProps}) => { const store = useStore(); return ( Loading}> ); -}); +}; + +const MyApp = props => { + const store = wrapper.useStore(); + return ( + + + + ); +}; + +export default MyApp; ``` And then in Next.js page: @@ -1149,19 +732,134 @@ And then in Next.js page: ```js // pages/index.js import React from 'react'; -import {connect} from 'react-redux'; +import {useSelector, useDispatch} from 'react-redux'; -export default connect(state => state, {setClientState})(({fromServer, fromClient, setClientState}) => ( -
-
fromServer: {fromServer}
-
fromClient: {fromClient}
+export default ({fromServer, fromClient, setClientState}) => { + const {fromServer, fromClient} = useSelector(state => state); + const dispatch = useDispatch(); + return (
- +
fromServer: {fromServer}
+
fromClient: {fromClient}
+
+ +
-
-)); + ); +}; ``` +## Usage with old class-based components + +### App + +If you're still using old class-based + +```js +class MyApp extends React.Component { + public static getInitialProps = wrapper.getInitialAppProps(store => async context => { + // https://nextjs.org/docs/advanced-features/custom-app#caveats + const pageProps = (await App.getInitialProps(context)).pageProps; + return {pageProps}; + }); + + public render() { + const {Component, pageProps} = this.props; + return ; + } +} + +const withStore = (Component) => { + + const WrappedComponent = (props: 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; +}; + +export default withStore(MyApp); +``` + +:warning: Do not use `class MyApp extends App`, use `class MyApp extends React.Component` :warning: + +Next.js provides [generic `getInitialProps`](https://github.com/vercel/next.js/blob/canary/packages/next/src/pages/_app.tsx#L39) which will be picked up by wrapper, so you **must not extend `App`** as you'll be opted out of Automatic Static Optimization: https://err.sh/next.js/opt-out-auto-static-optimization. Just export a regular Functional Component or extend `React.Component` as in the example above. + +### Pages + +```js +function DefaultLoading() { + return null; +} + +// put this into your library +const withHydration = (Component: NextComponentType | any, {Loading = DefaultLoading}: {Loading?: React.ComponentType}) => { + const WrappedComponent = (props: any) => (wrapper.useHydration(props).loading ? : ); + + WrappedComponent.displayName = `withHydration(${Component.displayName || Component.name || 'Component'})`; + + if ('getInitialProps' in Component) { + WrappedComponent.getInitialProps = Component.getInitialProps; + } + + return WrappedComponent; +}; + +class Page extends React.Component { + static getInitialProps = wrapper.getInitialPageProps(state => ({req}) => {}); + + render() { + return
{this.props.xxx}
; + } +} + +// and apply withHydration to all class-based pages +export default connect(state => state)( + withHydration(Page, { + Loading() { + return
Loading...
; + }, + }), +); +``` + +## Upgrade from 8.x to 9.x + +1. `HYDRATE` action has been removed, all actions are replayed as-is + +2. `addStoreToContext` option is discontinued + +3. Pages wrapped with App, that has `getInitialProps` will not receive `store` in `context`, change: + + ``` + public static async getInitialProps({store, pathname, query, req}: NextPageContext) { + ``` + + to + + ``` + public static getInitialProps = wrapper.getInitialPageProps(store => async ({pathname, query, req}) => { + ``` + +4. `const {store, props} = wrapper.useWrappedStore(rest);` is now `const store = wrapper.useStore();` + +5. Each page need to call `wrapper.useHydration(props)` + +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` + +8. `const makeStore = (context) => {...}` is now `const makeStore = ({context, reduxWrapperMiddleware})`, you must add `reduxWrapperMiddleware` to your store + ## Upgrade from 6.x to 7.x 1. Signature of `createWrapper` has changed: instead of `createWrapper` you should use `createWrapper>`, all types will be automatically inferred from `Store`. @@ -1204,7 +902,7 @@ If your project was using Next.js 5 and Next Redux Wrapper 1.x these instruction 2. Replace all usages of `import withRedux from "next-redux-wrapper";` and `withRedux(...)(WrappedComponent)` in all your pages with plain React Redux `connect` HOC: - ```js + ``` import {connect} from "react-redux"; export default connect(...)(WrappedComponent); @@ -1214,7 +912,7 @@ If your project was using Next.js 5 and Next Redux Wrapper 1.x these instruction 3. Create the `pages/_app.js` file with the following minimal code: - ```js + ``` // pages/_app.js import React from 'react' import {Provider} from 'react-redux'; diff --git a/package.json b/package.json index 1d939b5..abd4294 100644 --- a/package.json +++ b/package.json @@ -13,20 +13,19 @@ "test:quick": "lerna run test:quick --stream --concurrency=1", "publish:release": "lerna publish --tag-version-prefix=\"\" --force-publish=* --no-push --no-git-tag-version", "prettier": "prettier --write --ignore-path=.eslintignore --loglevel=warn", - "eslint": "eslint --cache --cache-location node_modules/.cache/eslint --fix", - "lint": "eslint --cache --cache-location .eslint/cache --fix", - "lint:all": "yarn eslint . && yarn prettier .", + "lint": "eslint --cache --cache-location node_modules/.cache/eslint --fix", + "lint:all": "yarn lint . && yarn prettier .", "lint:staged": "lint-staged --debug" }, "devDependencies": { - "eslint": "8.29.0", + "eslint": "8.33.0", "eslint-config-ringcentral-typescript": "7.0.3", "husky": "7.0.4", "lerna": "4.0.0", "lint-staged": "11.1.2", - "prettier": "2.5.1", - "rimraf": "3.0.2", - "typescript": "4.5.2" + "prettier": "2.8.3", + "rimraf": "4.1.2", + "typescript": "4.5.5" }, "workspaces": [ "packages/*" @@ -43,6 +42,7 @@ "license": "MIT", "packageManager": "yarn@3.0.2", "resolutions": { - "@types/react": "17.0.37" + "@types/react": "18.0.27", + "typescript": "4.5.5" } } diff --git a/packages/configs/playwright.js b/packages/configs/playwright.js index 4ad0b18..b844562 100644 --- a/packages/configs/playwright.js +++ b/packages/configs/playwright.js @@ -1,4 +1,17 @@ +/** @type {import('@playwright/test').PlaywrightTestConfig} */ module.exports = { + use: { + trace: process.env.CI ? 'retain-on-failure' : 'off', + video: 'retain-on-failure', + screenshot: 'on', + }, + outputDir: process.cwd() + '/test-results/out', + reporter: [ + ['html', {outputFolder: process.cwd() + '/test-results/html', open: 'never'}], + ['list'], + ['junit', {outputFile: process.cwd() + '/test-results/junit.xml'}], + ['json', {outputFile: process.cwd() + '/test-results/results.json'}], + ], webServer: { command: 'yarn start', port: 3000, diff --git a/packages/demo-page/next-env.d.ts b/packages/demo-page/next-env.d.ts index 9bc3dd4..4f11a03 100644 --- a/packages/demo-page/next-env.d.ts +++ b/packages/demo-page/next-env.d.ts @@ -1,5 +1,4 @@ /// -/// /// // NOTE: This file should not be edited diff --git a/packages/demo-page/next.config.js b/packages/demo-page/next.config.js new file mode 100644 index 0000000..cb15d13 --- /dev/null +++ b/packages/demo-page/next.config.js @@ -0,0 +1,5 @@ +/** @type {import('next').NextConfig} */ +module.exports = { + // transpilePackages: ['next-redux-wrapper'], // @see https://nextjs.org/docs/advanced-features/compiler#module-transpilation + swcMinify: true, +}; diff --git a/packages/demo-page/package.json b/packages/demo-page/package.json index 9716fd3..6b128d2 100644 --- a/packages/demo-page/package.json +++ b/packages/demo-page/package.json @@ -11,23 +11,23 @@ "dependencies": { "jsondiffpatch": "0.4.1", "next-redux-wrapper": "*", - "react": "17.0.2", - "react-dom": "17.0.2", + "react": "18.2.0", + "react-dom": "18.2.0", "react-redux": "7.2.6", "redux": "4.1.2", "redux-logger": "3.0.6" }, "devDependencies": { - "@playwright/test": "1.17.1", - "@types/react": "17.0.37", - "@types/react-dom": "17.0.11", + "@playwright/test": "1.30.0", + "@types/react": "18.0.27", + "@types/react-dom": "18.0.10", "@types/react-redux": "7.1.20", "@types/redux-logger": "3.0.9", - "next": "12.0.4", + "next": "13.1.6", "next-redux-wrapper-configs": "*", - "playwright": "1.17.1", + "playwright": "1.30.0", "rimraf": "3.0.2", - "typescript": "4.5.2" + "typescript": "4.9.5" }, "author": "Kirill Konshin", "repository": { diff --git a/packages/demo-page/src/components/reducer.tsx b/packages/demo-page/src/components/reducer.tsx index be2b9c8..230ee84 100644 --- a/packages/demo-page/src/components/reducer.tsx +++ b/packages/demo-page/src/components/reducer.tsx @@ -1,6 +1,4 @@ import {AnyAction} from 'redux'; -import {HYDRATE} from 'next-redux-wrapper'; -import {diff} from 'jsondiffpatch'; export interface State { page: string; @@ -8,18 +6,6 @@ export interface State { const reducer = (state: State = {page: 'init'}, action: AnyAction) => { switch (action.type) { - case HYDRATE: - const stateDiff = diff(state, action.payload) as any; - const wasBumpedOnClient = stateDiff?.page?.[0]?.endsWith('X'); - console.log('HYDRATE action handler', { - stateDiff, - wasBumpedOnClient, - }); - return { - ...state, - ...action.payload, - page: wasBumpedOnClient ? state.page : action.payload.page, - }; case 'PAGE': return {...state, page: action.payload}; case 'BUMP': diff --git a/packages/demo-page/src/components/store.tsx b/packages/demo-page/src/components/store.tsx index c36c698..9ae0f2d 100644 --- a/packages/demo-page/src/components/store.tsx +++ b/packages/demo-page/src/components/store.tsx @@ -1,10 +1,10 @@ import {createStore, applyMiddleware, Store} from 'redux'; import logger from 'redux-logger'; -import {createWrapper, Context} from 'next-redux-wrapper'; +import {createWrapper} from 'next-redux-wrapper'; import reducer, {State} from './reducer'; -export const makeStore = (context: Context) => { - const store = createStore(reducer, applyMiddleware(logger)); +export const makeStore = ({context, reduxWrapperMiddleware}) => { + const store = createStore(reducer, applyMiddleware(...[process.browser ? logger : null, reduxWrapperMiddleware].filter(Boolean))); if ((module as any).hot) { (module as any).hot.accept('./reducer', () => { diff --git a/packages/demo-page/src/pages/_app.tsx b/packages/demo-page/src/pages/_app.tsx index aa05c03..2954eeb 100644 --- a/packages/demo-page/src/pages/_app.tsx +++ b/packages/demo-page/src/pages/_app.tsx @@ -1,7 +1,15 @@ import React, {FC} from 'react'; import {AppProps} from 'next/app'; +import {Provider} from 'react-redux'; import {wrapper} from '../components/store'; -const MyApp: FC = ({Component, pageProps}) => ; +const MyApp: FC = function MyApp({Component, pageProps}) { + const store = wrapper.useStore(); + return ( + + + + ); +}; -export default wrapper.withRedux(MyApp); +export default MyApp; diff --git a/packages/demo-page/src/pages/index.tsx b/packages/demo-page/src/pages/index.tsx index f52a60c..73e0e45 100644 --- a/packages/demo-page/src/pages/index.tsx +++ b/packages/demo-page/src/pages/index.tsx @@ -10,7 +10,8 @@ export interface ConnectedPageProps { } // Page itself is not connected to Redux Store, it has to render Provider to allow child components to connect to Redux Store -const Page: NextPage = ({custom}) => { +const Page: NextPage = ({custom, ...props}: any) => { + wrapper.useHydration(props); const {page} = useSelector(state => state); return (
@@ -23,7 +24,6 @@ const Page: NextPage = ({custom}) => { }; export const getServerSideProps = wrapper.getServerSideProps(store => async ({req}) => { - console.log('2. Page.getServerSideProps uses the store to dispatch things'); store.dispatch({ type: 'PAGE', payload: 'was set in index page ' + req.url, diff --git a/packages/demo-page/src/pages/other.tsx b/packages/demo-page/src/pages/other.tsx index ecfeb9d..953ad98 100644 --- a/packages/demo-page/src/pages/other.tsx +++ b/packages/demo-page/src/pages/other.tsx @@ -6,7 +6,6 @@ import {State} from '../components/reducer'; import {wrapper} from '../components/store'; export const getStaticProps = wrapper.getStaticProps(store => async ({previewData}) => { - console.log('2. Page.getStaticProps uses the store to dispatch things'); store.dispatch({ type: 'PAGE', payload: 'was set in other page ' + JSON.stringify({previewData}), @@ -14,7 +13,8 @@ export const getStaticProps = wrapper.getStaticProps(store => async ({previewDat return {props: {}}; }); -const OtherPage: NextPage = () => { +const OtherPage: NextPage = props => { + wrapper.useHydration(props); const {page} = useSelector(state => state); const dispatch = useDispatch(); const bump = () => dispatch({type: 'BUMP'}); diff --git a/packages/demo-page/src/pages/other2.tsx b/packages/demo-page/src/pages/other2.tsx index cc40b20..fdf22e2 100644 --- a/packages/demo-page/src/pages/other2.tsx +++ b/packages/demo-page/src/pages/other2.tsx @@ -6,7 +6,6 @@ import {State} from '../components/reducer'; import {wrapper} from '../components/store'; export const getStaticProps = wrapper.getStaticProps(store => async ({previewData}) => { - console.log('2. Page.getStaticProps uses the store to dispatch things'); store.dispatch({ type: 'PAGE', payload: 'was set in other (SECOND) page ' + JSON.stringify({previewData}), @@ -14,7 +13,8 @@ export const getStaticProps = wrapper.getStaticProps(store => async ({previewDat return {props: {}}; }); -const OtherPage: NextPage = () => { +const OtherPage: NextPage = props => { + wrapper.useHydration(props); const {page} = useSelector(state => state); const dispatch = useDispatch(); const bump = () => dispatch({type: 'BUMP'}); diff --git a/packages/demo-page/src/pages/pageProps.tsx b/packages/demo-page/src/pages/pageProps.tsx index 865428b..d839346 100644 --- a/packages/demo-page/src/pages/pageProps.tsx +++ b/packages/demo-page/src/pages/pageProps.tsx @@ -5,6 +5,7 @@ import {State} from '../components/reducer'; import {wrapper} from '../components/store'; const PropsPage: NextPage = props => { + wrapper.useHydration(props); return (

Using Next.js default prop in a wrapped component.

@@ -16,7 +17,7 @@ const PropsPage: NextPage = props => { ); }; -PropsPage.getInitialProps = wrapper.getInitialPageProps(store => async () => ({ +(PropsPage as any).getInitialProps = wrapper.getInitialPageProps(store => async () => ({ prop: 'foo', })); diff --git a/packages/demo-page/tests/index.spec.ts b/packages/demo-page/tests/index.spec.ts index 92d641c..c640d98 100644 --- a/packages/demo-page/tests/index.spec.ts +++ b/packages/demo-page/tests/index.spec.ts @@ -1,9 +1,9 @@ import {test, expect, Page} from '@playwright/test'; -const openPage = (page: Page, url = '/') => page.goto(`http://localhost:4000${url}`); +const openPage = (page: Page, baseURL: string | undefined, url = '/') => page.goto(`${baseURL}${url}`); -test('shows the page', async ({page}) => { - await openPage(page); +test('shows the page', async ({page, baseURL}) => { + await openPage(page, baseURL); await page.waitForSelector('div.index'); @@ -11,8 +11,8 @@ test('shows the page', async ({page}) => { await expect(page.locator('body')).toContainText('"custom": "custom"'); }); -test('clicks the button', async ({page}) => { - await openPage(page, '/other'); +test('clicks the button', async ({page, baseURL}) => { + await openPage(page, baseURL, '/other'); await page.waitForSelector('div.other'); @@ -26,10 +26,10 @@ test('clicks the button', async ({page}) => { await expect(page.locator('body')).toContainText('"custom": "custom"'); }); -test('initial page props', async ({page}) => { - await openPage(page, '/pageProps'); +test('initial page props', async ({page, baseURL}) => { + await openPage(page, baseURL, '/pageProps'); await page.waitForSelector('div.pageProps'); - await expect(page.locator('body')).toContainText('{"prop":"foo"}'); + await expect(page.locator('body')).toContainText('"prop":"foo"'); }); diff --git a/packages/demo-redux-toolkit/next-env.d.ts b/packages/demo-redux-toolkit/next-env.d.ts index 9bc3dd4..4f11a03 100644 --- a/packages/demo-redux-toolkit/next-env.d.ts +++ b/packages/demo-redux-toolkit/next-env.d.ts @@ -1,5 +1,4 @@ /// -/// /// // NOTE: This file should not be edited diff --git a/packages/demo-redux-toolkit/next.config.js b/packages/demo-redux-toolkit/next.config.js new file mode 100644 index 0000000..cb15d13 --- /dev/null +++ b/packages/demo-redux-toolkit/next.config.js @@ -0,0 +1,5 @@ +/** @type {import('next').NextConfig} */ +module.exports = { + // transpilePackages: ['next-redux-wrapper'], // @see https://nextjs.org/docs/advanced-features/compiler#module-transpilation + swcMinify: true, +}; diff --git a/packages/demo-redux-toolkit/package.json b/packages/demo-redux-toolkit/package.json index dafd432..93fb0f5 100644 --- a/packages/demo-redux-toolkit/package.json +++ b/packages/demo-redux-toolkit/package.json @@ -10,15 +10,18 @@ "dependencies": { "@reduxjs/toolkit": "1.8.6", "next-redux-wrapper": "*", - "react": "17.0.2", - "react-dom": "17.0.2", + "react": "18.2.0", + "react-dom": "18.2.0", "react-redux": "7.2.6", - "redux": "4.1.2" + "redux": "4.1.2", + "redux-logger": "3.0.6" }, "devDependencies": { - "@playwright/test": "1.17.1", - "next": "12.0.4", - "playwright": "1.17.1" + "@playwright/test": "1.30.0", + "@types/react": "18.0.27", + "@types/react-dom": "18.0.10", + "next": "13.1.6", + "playwright": "1.30.0" }, "license": "MIT" } diff --git a/packages/demo-redux-toolkit/pages/_app.tsx b/packages/demo-redux-toolkit/pages/_app.tsx index c0aab15..13f5d29 100644 --- a/packages/demo-redux-toolkit/pages/_app.tsx +++ b/packages/demo-redux-toolkit/pages/_app.tsx @@ -1,35 +1,34 @@ -import React from 'react'; +import React, {FC} from 'react'; import {Provider} from 'react-redux'; import App, {AppProps} from 'next/app'; import {fetchSystem, wrapper} from '../store'; -interface PageProps { - pageProps: { - id: number; - }; -} +const MyApp: FC = function MyApp({Component, pageProps}) { + console.log('rest: ', pageProps); -const MyApp = ({Component, ...rest}: Omit & PageProps) => { - console.log('rest: ', rest); - const {store, props} = wrapper.useWrappedStore(rest); + const store = wrapper.useStore(); return ( -

PageProps.id: {rest.pageProps.id}

- +

PageProps.id: {pageProps.id}

+
); }; -MyApp.getInitialProps = wrapper.getInitialAppProps(store => async (appCtx): Promise => { +//FIXME This is not a recommended approach, only used here for demo purposes +(MyApp as any).getInitialProps = wrapper.getInitialAppProps(store => async appCtx => { // You have to do dispatches first, before... await store.dispatch(fetchSystem()); // ...before calling (and awaiting!!!!) the children's getInitialProps + // @see https://nextjs.org/docs/advanced-features/custom-app#caveats const childrenGip = await App.getInitialProps(appCtx); + return { pageProps: { // And you have to spread the children's GIP result into pageProps + // @see https://nextjs.org/docs/advanced-features/custom-app#caveats ...childrenGip.pageProps, id: 42, }, diff --git a/packages/demo-redux-toolkit/pages/detail/[id].tsx b/packages/demo-redux-toolkit/pages/detail/[id].tsx index 18d2858..8949689 100644 --- a/packages/demo-redux-toolkit/pages/detail/[id].tsx +++ b/packages/demo-redux-toolkit/pages/detail/[id].tsx @@ -12,7 +12,9 @@ import { wrapper, } from '../../store'; -const Page: NextPage> = ({serverTimestamp}) => { +const Page: NextPage> = function Detail({serverTimestamp, ...rest}) { + wrapper.useHydration(rest); + console.log('State on render', useStore().getState()); console.log('Timestamp on server: ', serverTimestamp); const dispatch = useDispatch(); @@ -25,7 +27,7 @@ const Page: NextPage> = ( console[pageSummary ? 'info' : 'warn']('Rendered pageName: ', pageSummary); if (!pageSummary || !pageId || !data) { - throw new Error('Whoops! We do not have the pageId and pageSummary selector data!'); + return
Loading
; } return ( diff --git a/packages/demo-redux-toolkit/pages/gipp.tsx b/packages/demo-redux-toolkit/pages/gipp.tsx index 906a247..2229af4 100644 --- a/packages/demo-redux-toolkit/pages/gipp.tsx +++ b/packages/demo-redux-toolkit/pages/gipp.tsx @@ -8,7 +8,8 @@ interface Props { name: string; } -const Page: NextPage = ({name}) => { +const Page: NextPage = ({name, ...props}: any) => { + wrapper.useHydration(props); console.log('State on render', useStore().getState()); const dispatch = useDispatch(); const testData = useSelector(selectGippPageTestData); @@ -19,7 +20,7 @@ const Page: NextPage = ({name}) => { console[testData ? 'info' : 'warn']('Rendered testData: ', testData); if (!testData || !data) { - throw new Error('Whoops! We do not have the data and testData selector data!'); + return
Loading...
; } return ( diff --git a/packages/demo-redux-toolkit/pages/pokemon/[pokemon].tsx b/packages/demo-redux-toolkit/pages/pokemon/[pokemon].tsx index e308ae4..a41c1c2 100644 --- a/packages/demo-redux-toolkit/pages/pokemon/[pokemon].tsx +++ b/packages/demo-redux-toolkit/pages/pokemon/[pokemon].tsx @@ -3,14 +3,15 @@ import {useRouter} from 'next/router'; import {wrapper, pokemonApi, useGetPokemonByNameQuery} from '../../store'; import {useStore} from 'react-redux'; -export default function Pokemon() { +export default function Pokemon(props: any) { + wrapper.useHydration(props); const {query} = useRouter(); console.log('State on render', useStore().getState()); const {data} = useGetPokemonByNameQuery(query.pokemon as string); // data is undefined for the first render if (!data) { - throw new Error('Whoops! We do not have the data selector data!'); + return
Loading
; } return
Name: {data?.name}
; diff --git a/packages/demo-redux-toolkit/pages/subject/[id].tsx b/packages/demo-redux-toolkit/pages/subject/[id].tsx index 59d0b2c..3c1fbec 100644 --- a/packages/demo-redux-toolkit/pages/subject/[id].tsx +++ b/packages/demo-redux-toolkit/pages/subject/[id].tsx @@ -4,9 +4,12 @@ import Link from 'next/link'; import {InferGetServerSidePropsType, NextPage} from 'next'; import {fetchSubject, selectSubjectPageId, selectSubjectPageName, selectSubjectPageStateTimestamp, wrapper} from '../../store'; -const Page: NextPage> = ({serverTimestamp}) => { +const Page: NextPage> = function Subject({serverTimestamp, ...props}) { + const {hydrating} = wrapper.useHydration(props); + console.log('State on render', useStore().getState()); console.log('Timestamp on server: ', serverTimestamp); + console.log('Hydrating:', hydrating); const dispatch = useDispatch(); const pageId = useSelector(selectSubjectPageId); const pageName = useSelector(selectSubjectPageName); @@ -15,7 +18,7 @@ const Page: NextPage> = ( console[pageName ? 'info' : 'warn']('Rendered pageName: ', pageName); if (!pageName || !pageId) { - throw new Error('Whoops! We do not have the pageId and pageName selector data!'); + return
Loading
; } return ( diff --git a/packages/demo-redux-toolkit/store.ts b/packages/demo-redux-toolkit/store.ts index 9be0385..0ff0092 100644 --- a/packages/demo-redux-toolkit/store.ts +++ b/packages/demo-redux-toolkit/store.ts @@ -1,7 +1,8 @@ import {configureStore, createSelector, createSlice, PayloadAction, ThunkAction} from '@reduxjs/toolkit'; import {createApi, fetchBaseQuery} from '@reduxjs/toolkit/query/react'; import {Action, combineReducers} from 'redux'; -import {createWrapper, HYDRATE} from 'next-redux-wrapper'; +import {createWrapper, MakeStore} from 'next-redux-wrapper'; +import logger from 'redux-logger'; // System model interface SystemData { @@ -25,16 +26,6 @@ const systemSlice = createSlice({ state.data = payload.data; }, }, - extraReducers: { - [HYDRATE]: (state, action) => { - console.log('HYDRATE system', action.payload); - - return { - ...state, - ...action.payload.system, - }; - }, - }, }); // Subject page model @@ -61,16 +52,6 @@ const subjectPageSlice = createSlice({ state.data = payload.data; }, }, - extraReducers: { - [HYDRATE]: (state, action) => { - console.log('HYDRATE subjectPage', action.payload); - - return { - ...state, - ...action.payload.subjectPage, - }; - }, - }, }); // Detail page model @@ -104,16 +85,6 @@ const detailPageSlice = createSlice({ state.data = payload.data; }, }, - extraReducers: { - [HYDRATE]: (state, action) => { - console.log('HYDRATE detailPage', action.payload); - - return { - ...state, - ...action.payload.detailPage, - }; - }, - }, }); // Gipp page model @@ -144,16 +115,6 @@ const gippPageSlice = createSlice({ state.data = payload.data; }, }, - extraReducers: { - [HYDRATE]: (state, action) => { - console.log('HYDRATE gippPage', action.payload); - - return { - ...state, - ...action.payload.gippPage, - }; - }, - }, }); interface Pokemon { @@ -164,11 +125,6 @@ interface Pokemon { export const pokemonApi = createApi({ reducerPath: 'pokemonApi', baseQuery: fetchBaseQuery({baseUrl: 'https://pokeapi.co/api/v2'}), - extractRehydrationInfo(action, {reducerPath}) { - if (action.type === HYDRATE) { - return action.payload[reducerPath]; - } - }, endpoints: builder => ({ getPokemonByName: builder.query({ query: name => `/pokemon/${name}`, @@ -189,18 +145,21 @@ const reducers = { const reducer = combineReducers(reducers); -const makeStore = () => +const makeStore: MakeStore = ({reduxWrapperMiddleware}) => configureStore({ reducer, devTools: true, - middleware: getDefaultMiddleware => getDefaultMiddleware().concat(pokemonApi.middleware), + middleware: getDefaultMiddleware => + [...getDefaultMiddleware(), process.browser ? logger : null, pokemonApi.middleware, reduxWrapperMiddleware].filter( + Boolean, + ) as any, }); type AppStore = ReturnType; export type AppState = ReturnType; type AppThunk = ThunkAction; -export const wrapper = createWrapper(makeStore); +export const wrapper = createWrapper(makeStore, {debug: true}); // System thunk export const fetchSystem = (): AppThunk => async dispatch => { @@ -273,16 +232,16 @@ export const fetchGipp = (): AppThunk => async dispatch => { }; // System selectors -const systemSliceSelector = (state: AppState): SystemState => state.system; +const systemSliceSelector = (state: AppState): SystemState => state?.system; const selectSystemData = createSelector(systemSliceSelector, s => s.data); export const selectSystemSource = createSelector(selectSystemData, s => s?.source); // Subject page selectors -const subjectPageSliceSelector = (state: AppState): SubjectPageState => state.subjectPage; +const subjectPageSliceSelector = (state: AppState): SubjectPageState => state?.subjectPage; -const selectSubjectPageData = createSelector(subjectPageSliceSelector, s => s.data); +const selectSubjectPageData = createSelector(subjectPageSliceSelector, s => s?.data); // The correct way with strict typing on export const selectSubjectPageId = createSelector(selectSubjectPageData, s => s?.id); @@ -295,26 +254,24 @@ export const selectSubjectPageStateTimestamp = createSelector(selectSubjectPageD // to undefined/null. // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore -export const selectSubjectPageName = createSelector(selectSubjectPageData, s => s.name); +export const selectSubjectPageName = createSelector(selectSubjectPageData, s => s?.name); // Detail page selectors const detailPageSliceSelector = (state: AppState): DetailPageState => state.detailPage; -export const selectDetailPageData = createSelector(detailPageSliceSelector, s => s.data); +export const selectDetailPageData = createSelector(detailPageSliceSelector, s => s?.data); // The correct way with strict typing on export const selectDetailPageId = createSelector(selectDetailPageData, s => s?.id); export const selectDetailPageStateTimestamp = createSelector(selectDetailPageData, s => s?.stateTimestamp); // The incorrect way with strict typing off. See comment above. -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore -export const selectDetailPageSummary = createSelector(selectDetailPageData, s => s.summary); +export const selectDetailPageSummary = createSelector(selectDetailPageData, s => s?.summary); // Gipp page selectors const gippPageSliceSelector = (state: AppState): GippPageState => state.gippPage; -export const selectGippPageData = createSelector(gippPageSliceSelector, s => s.data); +export const selectGippPageData = createSelector(gippPageSliceSelector, s => s?.data); // The correct way with strict typing on export const selectGippPageId = createSelector(selectGippPageData, s => s?.id); diff --git a/packages/demo-redux-toolkit/tests/index.spec.ts b/packages/demo-redux-toolkit/tests/index.spec.ts index 08e23e6..0f7674a 100644 --- a/packages/demo-redux-toolkit/tests/index.spec.ts +++ b/packages/demo-redux-toolkit/tests/index.spec.ts @@ -1,9 +1,9 @@ -import {test, expect, Page, chromium} from '@playwright/test'; +import {test, expect, Page} from '@playwright/test'; -const openPage = (page: Page, url = '/') => page.goto(`http://localhost:6060${url}`); +const openPage = (page: Page, baseURL: string | undefined, url = '/') => page.goto(`${baseURL}${url}`); -test('shows the page', async ({page}) => { - await openPage(page); +test('shows the page', async ({page, baseURL}) => { + await openPage(page, baseURL); // Problem page 1 await page.click('text=Go to problem pages'); @@ -45,52 +45,29 @@ test('shows the page', async ({page}) => { await expect(page.locator('body')).toContainText('Page name: GIPP'); }); -test('server rendered subject page', async () => { - const browser = await chromium.launch(); +test.describe('server rendered', () => { + test.use({javaScriptEnabled: false}); - const context = await browser.newContext({ - javaScriptEnabled: false, - }); - const page = await context.newPage(); - - await openPage(page, '/subject/1'); - - await expect(page.locator('body')).toContainText('Subject 1'); - - await browser.close(); -}); - -test('server rendered detail page', async () => { - const browser = await chromium.launch(); + test('subject page', async ({page, baseURL}) => { + await openPage(page, baseURL, '/subject/1'); - const context = await browser.newContext({ - javaScriptEnabled: false, + await expect(page.locator('body')).toContainText('Subject 1'); }); - const page = await context.newPage(); - - await openPage(page, '/detail/1'); - - await expect(page.locator('body')).toContainText('PageProps.id: 42'); - await expect(page.locator('body')).toContainText('System source: GIAP'); - await expect(page.locator('body')).toContainText('This is the summary for the page with id 1'); - await browser.close(); -}); + test('detail page', async ({page, baseURL}) => { + await openPage(page, baseURL, '/detail/1'); -test('server rendered gipp page', async () => { - const browser = await chromium.launch(); - - const context = await browser.newContext({ - javaScriptEnabled: false, + await expect(page.locator('body')).toContainText('PageProps.id: 42'); + await expect(page.locator('body')).toContainText('System source: GIAP'); + await expect(page.locator('body')).toContainText('This is the summary for the page with id 1'); }); - const page = await context.newPage(); - - await openPage(page, '/gipp'); - await expect(page.locator('body')).toContainText('PageProps.id: 42'); - await expect(page.locator('body')).toContainText('System source: GIAP'); - await expect(page.locator('body')).toContainText('This is the test data for the gipp page'); - await expect(page.locator('body')).toContainText('Page name: GIPP'); + test('gipp page', async ({page, baseURL}) => { + await openPage(page, baseURL, '/gipp'); - await browser.close(); + await expect(page.locator('body')).toContainText('PageProps.id: 42'); + await expect(page.locator('body')).toContainText('System source: GIAP'); + await expect(page.locator('body')).toContainText('This is the test data for the gipp page'); + await expect(page.locator('body')).toContainText('Page name: GIPP'); + }); }); diff --git a/packages/demo-saga-page/next-env.d.ts b/packages/demo-saga-page/next-env.d.ts index 9bc3dd4..4f11a03 100644 --- a/packages/demo-saga-page/next-env.d.ts +++ b/packages/demo-saga-page/next-env.d.ts @@ -1,5 +1,4 @@ /// -/// /// // NOTE: This file should not be edited diff --git a/packages/demo-saga-page/next.config.js b/packages/demo-saga-page/next.config.js new file mode 100644 index 0000000..cb15d13 --- /dev/null +++ b/packages/demo-saga-page/next.config.js @@ -0,0 +1,5 @@ +/** @type {import('next').NextConfig} */ +module.exports = { + // transpilePackages: ['next-redux-wrapper'], // @see https://nextjs.org/docs/advanced-features/compiler#module-transpilation + swcMinify: true, +}; diff --git a/packages/demo-saga-page/package.json b/packages/demo-saga-page/package.json index d8f35a1..8655b03 100644 --- a/packages/demo-saga-page/package.json +++ b/packages/demo-saga-page/package.json @@ -11,24 +11,24 @@ "dependencies": { "jsondiffpatch": "0.4.1", "next-redux-wrapper": "*", - "react": "17.0.2", - "react-dom": "17.0.2", + "react": "18.2.0", + "react-dom": "18.2.0", "react-redux": "7.2.6", "redux": "4.1.2", "redux-logger": "3.0.6", "redux-saga": "1.1.3" }, "devDependencies": { - "@playwright/test": "1.17.1", - "@types/react": "17.0.37", - "@types/react-dom": "17.0.11", + "@playwright/test": "1.30.0", + "@types/react": "18.0.27", + "@types/react-dom": "18.0.10", "@types/react-redux": "7.1.20", "@types/redux-logger": "3.0.9", - "next": "12.0.4", + "next": "13.1.6", "next-redux-wrapper-configs": "*", - "playwright": "1.17.1", + "playwright": "1.30.0", "rimraf": "3.0.2", - "typescript": "4.5.2" + "typescript": "4.9.5" }, "author": "Kirill Konshin", "repository": { diff --git a/packages/demo-saga-page/src/components/reducer.tsx b/packages/demo-saga-page/src/components/reducer.tsx index a2cd4f0..b595849 100644 --- a/packages/demo-saga-page/src/components/reducer.tsx +++ b/packages/demo-saga-page/src/components/reducer.tsx @@ -1,5 +1,4 @@ import {AnyAction} from 'redux'; -import {HYDRATE} from 'next-redux-wrapper'; import {SAGA_ACTION_SUCCESS} from './saga'; export interface State { @@ -10,8 +9,6 @@ const initialState: State = {page: ''}; function rootReducer(state = initialState, action: AnyAction) { switch (action.type) { - case HYDRATE: - return {...state, ...action.payload}; case SAGA_ACTION_SUCCESS: return {...state, page: action.data}; default: diff --git a/packages/demo-saga-page/src/components/store.tsx b/packages/demo-saga-page/src/components/store.tsx index 04b849c..06f2dc2 100644 --- a/packages/demo-saga-page/src/components/store.tsx +++ b/packages/demo-saga-page/src/components/store.tsx @@ -1,20 +1,23 @@ import {createStore, applyMiddleware, Store} from 'redux'; import logger from 'redux-logger'; import createSagaMiddleware, {Task} from 'redux-saga'; -import {Context, createWrapper} from 'next-redux-wrapper'; +import {createWrapper} from 'next-redux-wrapper'; import reducer, {State} from './reducer'; -import rootSaga from './saga'; +import rootSaga, {SAGA_ACTION} from './saga'; export interface SagaStore extends Store { sagaTask: Task; } -const makeStore = (context: Context) => { +const makeStore = ({reduxWrapperMiddleware}) => { // 1: Create the middleware const sagaMiddleware = createSagaMiddleware(); // 2: Add an extra parameter for applying middleware: - const store = createStore(reducer, applyMiddleware(sagaMiddleware, logger)); + const store = createStore( + reducer, + applyMiddleware(...[sagaMiddleware, process.browser ? logger : null, reduxWrapperMiddleware].filter(Boolean)), + ); // 3: Run your sagas on server (store as SagaStore).sagaTask = sagaMiddleware.run(rootSaga); @@ -23,4 +26,9 @@ const makeStore = (context: Context) => { return store; }; -export const wrapper = createWrapper(makeStore as any); +const filterActions = ['@@redux-saga/CHANNEL_END', SAGA_ACTION]; + +export const wrapper = createWrapper(makeStore as any, { + debug: true, + actionFilter: action => !filterActions.includes(action.type), +}); diff --git a/packages/demo-saga-page/src/pages/_app.tsx b/packages/demo-saga-page/src/pages/_app.tsx index aa05c03..2954eeb 100644 --- a/packages/demo-saga-page/src/pages/_app.tsx +++ b/packages/demo-saga-page/src/pages/_app.tsx @@ -1,7 +1,15 @@ import React, {FC} from 'react'; import {AppProps} from 'next/app'; +import {Provider} from 'react-redux'; import {wrapper} from '../components/store'; -const MyApp: FC = ({Component, pageProps}) => ; +const MyApp: FC = function MyApp({Component, pageProps}) { + const store = wrapper.useStore(); + return ( + + + + ); +}; -export default wrapper.withRedux(MyApp); +export default MyApp; diff --git a/packages/demo-saga-page/src/pages/index.tsx b/packages/demo-saga-page/src/pages/index.tsx index 3ade96f..0e2fedc 100644 --- a/packages/demo-saga-page/src/pages/index.tsx +++ b/packages/demo-saga-page/src/pages/index.tsx @@ -11,7 +11,8 @@ export interface ConnectedPageProps { } // Page itself is not connected to Redux Store, it has to render Provider to allow child components to connect to Redux Store -const Page: NextPage = ({custom}: ConnectedPageProps) => { +const Page: NextPage = ({custom, ...props}: ConnectedPageProps) => { + wrapper.useHydration(props); const {page} = useSelector(state => state); return (
diff --git a/packages/demo-saga-page/tests/index.spec.ts b/packages/demo-saga-page/tests/index.spec.ts index 93e7a29..c3341f6 100644 --- a/packages/demo-saga-page/tests/index.spec.ts +++ b/packages/demo-saga-page/tests/index.spec.ts @@ -1,9 +1,9 @@ import {test, expect, Page} from '@playwright/test'; -const openPage = (page: Page, url = '/') => page.goto(`http://localhost:5050${url}`); +const openPage = (page: Page, baseURL: string | undefined, url = '/') => page.goto(`${baseURL}${url}`); -test('shows the page', async ({page}) => { - await openPage(page); +test('shows the page', async ({page, baseURL}) => { + await openPage(page, baseURL); await page.waitForSelector('div.index'); diff --git a/packages/demo-saga/next-env.d.ts b/packages/demo-saga/next-env.d.ts index 9bc3dd4..4f11a03 100644 --- a/packages/demo-saga/next-env.d.ts +++ b/packages/demo-saga/next-env.d.ts @@ -1,5 +1,4 @@ /// -/// /// // NOTE: This file should not be edited diff --git a/packages/demo-saga/next.config.js b/packages/demo-saga/next.config.js new file mode 100644 index 0000000..cb15d13 --- /dev/null +++ b/packages/demo-saga/next.config.js @@ -0,0 +1,5 @@ +/** @type {import('next').NextConfig} */ +module.exports = { + // transpilePackages: ['next-redux-wrapper'], // @see https://nextjs.org/docs/advanced-features/compiler#module-transpilation + swcMinify: true, +}; diff --git a/packages/demo-saga/package.json b/packages/demo-saga/package.json index a27bd25..64d7f7f 100644 --- a/packages/demo-saga/package.json +++ b/packages/demo-saga/package.json @@ -6,29 +6,29 @@ "scripts": { "clean": "rimraf .next coverage", "test": "playwright test", - "start": "next --port=5000" + "start": "next --port=5055" }, "dependencies": { "jsondiffpatch": "0.4.1", "next-redux-wrapper": "*", - "react": "17.0.2", - "react-dom": "17.0.2", + "react": "18.2.0", + "react-dom": "18.2.0", "react-redux": "7.2.6", "redux": "4.1.2", "redux-logger": "3.0.6", "redux-saga": "1.1.3" }, "devDependencies": { - "@playwright/test": "1.17.1", - "@types/react": "17.0.37", - "@types/react-dom": "17.0.11", + "@playwright/test": "1.30.0", + "@types/react": "18.0.27", + "@types/react-dom": "18.0.10", "@types/react-redux": "7.1.20", "@types/redux-logger": "3.0.9", - "next": "12.0.4", + "next": "13.1.6", "next-redux-wrapper-configs": "*", - "playwright": "1.17.1", + "playwright": "1.30.0", "rimraf": "3.0.2", - "typescript": "4.5.2" + "typescript": "4.9.5" }, "author": "Kirill Konshin", "repository": { diff --git a/packages/demo-saga/playwright.config.ts b/packages/demo-saga/playwright.config.ts index 49e356c..94fd090 100644 --- a/packages/demo-saga/playwright.config.ts +++ b/packages/demo-saga/playwright.config.ts @@ -5,7 +5,7 @@ const config: PlaywrightTestConfig = { ...defaultConfig, webServer: { ...defaultConfig.webServer, - port: 5000, + port: 5055, }, }; diff --git a/packages/demo-saga/src/components/reducer.tsx b/packages/demo-saga/src/components/reducer.tsx index a2cd4f0..b595849 100644 --- a/packages/demo-saga/src/components/reducer.tsx +++ b/packages/demo-saga/src/components/reducer.tsx @@ -1,5 +1,4 @@ import {AnyAction} from 'redux'; -import {HYDRATE} from 'next-redux-wrapper'; import {SAGA_ACTION_SUCCESS} from './saga'; export interface State { @@ -10,8 +9,6 @@ const initialState: State = {page: ''}; function rootReducer(state = initialState, action: AnyAction) { switch (action.type) { - case HYDRATE: - return {...state, ...action.payload}; case SAGA_ACTION_SUCCESS: return {...state, page: action.data}; default: diff --git a/packages/demo-saga/src/components/store.tsx b/packages/demo-saga/src/components/store.tsx index 41d4070..3aada3f 100644 --- a/packages/demo-saga/src/components/store.tsx +++ b/packages/demo-saga/src/components/store.tsx @@ -1,20 +1,23 @@ import {createStore, applyMiddleware, Store} from 'redux'; import logger from 'redux-logger'; import createSagaMiddleware, {Task} from 'redux-saga'; -import {Context, createWrapper} from 'next-redux-wrapper'; +import {createWrapper} from 'next-redux-wrapper'; import reducer from './reducer'; -import rootSaga from './saga'; +import rootSaga, {SAGA_ACTION} from './saga'; export interface SagaStore extends Store { sagaTask: Task; } -export const makeStore = (context: Context) => { +export const makeStore = ({reduxWrapperMiddleware}) => { // 1: Create the middleware const sagaMiddleware = createSagaMiddleware(); // 2: Add an extra parameter for applying middleware: - const store = createStore(reducer, applyMiddleware(sagaMiddleware, logger)); + const store = createStore( + reducer, + applyMiddleware(...[sagaMiddleware, process.browser ? logger : null, reduxWrapperMiddleware].filter(Boolean)), + ); // 3: Run your sagas on server (store as SagaStore).sagaTask = sagaMiddleware.run(rootSaga); @@ -23,4 +26,9 @@ export const makeStore = (context: Context) => { return store; }; -export const wrapper = createWrapper(makeStore as any); +const filterActions = ['@@redux-saga/CHANNEL_END', SAGA_ACTION]; + +export const wrapper = createWrapper(makeStore as any, { + debug: true, + actionFilter: action => !filterActions.includes(action.type), +}); diff --git a/packages/demo-saga/src/pages/_app.tsx b/packages/demo-saga/src/pages/_app.tsx index 983ce0b..82ddf8a 100644 --- a/packages/demo-saga/src/pages/_app.tsx +++ b/packages/demo-saga/src/pages/_app.tsx @@ -1,30 +1,33 @@ -import React from 'react'; +import React, {FC} from 'react'; import App, {AppProps} from 'next/app'; import {END} from 'redux-saga'; +import {Provider} from 'react-redux'; import {SagaStore, wrapper} from '../components/store'; -class MyApp extends React.Component { - public static getInitialProps = wrapper.getInitialAppProps(store => async context => { - // 1. Wait for all page actions to dispatch - const pageProps = { - // https://nextjs.org/docs/advanced-features/custom-app#caveats - ...(await App.getInitialProps(context)).pageProps, - }; +const MyApp: FC = function MyApp({Component, pageProps}) { + const store = wrapper.useStore(); + return ( + + + + ); +}; - // 2. Stop the saga if on server - if (context.ctx.req) { - store.dispatch(END); - await (store as SagaStore).sagaTask.toPromise(); - } +(MyApp as any).getInitialProps = wrapper.getInitialAppProps(store => async context => { + // 1. Wait for all page actions to dispatch + const pageProps = { + // https://nextjs.org/docs/advanced-features/custom-app#caveats + ...(await App.getInitialProps(context as any)).pageProps, + }; - // 3. Return props - return {pageProps}; - }); - - public render() { - const {Component, pageProps} = this.props; - return ; + // 2. Stop the saga if on server + if (context.ctx.req) { + store.dispatch(END); + await (store as SagaStore).sagaTask.toPromise(); } -} -export default wrapper.withRedux(MyApp); + // 3. Return props + return {pageProps}; +}); + +export default MyApp; diff --git a/packages/demo-saga/src/pages/index.tsx b/packages/demo-saga/src/pages/index.tsx index b16ac80..c5db724 100644 --- a/packages/demo-saga/src/pages/index.tsx +++ b/packages/demo-saga/src/pages/index.tsx @@ -3,13 +3,15 @@ import {useSelector} from 'react-redux'; import {NextPage} from 'next'; import {State} from '../components/reducer'; import {SAGA_ACTION} from '../components/saga'; +import {wrapper} from '../components/store'; export interface ConnectedPageProps { custom: string; } // Page itself is not connected to Redux Store, it has to render Provider to allow child components to connect to Redux Store -const Page: NextPage = ({custom}: ConnectedPageProps) => { +const Page: NextPage = ({custom, ...props}: ConnectedPageProps) => { + wrapper.useHydration(props); const {page} = useSelector(state => state); return (
@@ -18,9 +20,9 @@ const Page: NextPage = ({custom}: ConnectedPageProps) => { ); }; -Page.getInitialProps = async ({store}) => { +Page.getInitialProps = wrapper.getInitialPageProps(store => async () => { store.dispatch({type: SAGA_ACTION}); return {custom: 'custom'}; -}; +}); export default Page; diff --git a/packages/demo-saga/tests/index.spec.ts b/packages/demo-saga/tests/index.spec.ts index 2e39eba..41b82fa 100644 --- a/packages/demo-saga/tests/index.spec.ts +++ b/packages/demo-saga/tests/index.spec.ts @@ -1,9 +1,9 @@ import {test, expect, Page} from '@playwright/test'; -const openPage = (page: Page, url = '/') => page.goto(`http://localhost:5000${url}`); +const openPage = (page: Page, baseURL, url = '/') => page.goto(`${baseURL}${url}`); -test('shows the page', async ({page}) => { - await openPage(page); +test('shows the page', async ({page, baseURL}) => { + await openPage(page, baseURL); await page.waitForSelector('div.index'); diff --git a/packages/demo/next-env.d.ts b/packages/demo/next-env.d.ts index 9bc3dd4..4f11a03 100644 --- a/packages/demo/next-env.d.ts +++ b/packages/demo/next-env.d.ts @@ -1,5 +1,4 @@ /// -/// /// // NOTE: This file should not be edited diff --git a/packages/demo/next.config.js b/packages/demo/next.config.js new file mode 100644 index 0000000..cb15d13 --- /dev/null +++ b/packages/demo/next.config.js @@ -0,0 +1,5 @@ +/** @type {import('next').NextConfig} */ +module.exports = { + // transpilePackages: ['next-redux-wrapper'], // @see https://nextjs.org/docs/advanced-features/compiler#module-transpilation + swcMinify: true, +}; diff --git a/packages/demo/package.json b/packages/demo/package.json index d3cc929..cb63d00 100644 --- a/packages/demo/package.json +++ b/packages/demo/package.json @@ -10,23 +10,24 @@ }, "dependencies": { "next-redux-wrapper": "*", - "react": "17.0.2", - "react-dom": "17.0.2", + "react": "18.2.0", + "react-dom": "18.2.0", "react-redux": "7.2.6", "redux": "4.1.2", - "redux-logger": "3.0.6" + "redux-logger": "3.0.6", + "redux-promise-middleware": "6.1.3" }, "devDependencies": { - "@playwright/test": "1.17.1", - "@types/react": "17.0.37", - "@types/react-dom": "17.0.11", + "@playwright/test": "1.30.0", + "@types/react": "18.0.27", + "@types/react-dom": "18.0.10", "@types/react-redux": "7.1.20", "@types/redux-logger": "3.0.9", - "next": "12.0.4", + "next": "13.1.6", "next-redux-wrapper-configs": "*", - "playwright": "1.17.1", + "playwright": "1.30.0", "rimraf": "3.0.2", - "typescript": "4.5.2" + "typescript": "4.9.5" }, "author": "Kirill Konshin", "repository": { diff --git a/packages/demo/src/components/reducer.tsx b/packages/demo/src/components/reducer.tsx index 193fc20..d17b2d7 100644 --- a/packages/demo/src/components/reducer.tsx +++ b/packages/demo/src/components/reducer.tsx @@ -1,25 +1,32 @@ import {AnyAction} from 'redux'; -import {HYDRATE} from 'next-redux-wrapper'; export interface State { app: string; page: string; + promise: string; + promiseApp: string; } -const reducer = (state: State = {app: 'init', page: 'init'}, action: AnyAction) => { +const reducer = (state: State = {app: 'init', page: 'init', promise: 'init', promiseApp: 'init'}, action: AnyAction) => { switch (action.type) { - case HYDRATE: - if (action.payload.app === 'init') { - delete action.payload.app; - } - if (action.payload.page === 'init') { - delete action.payload.page; - } - return {...state, ...action.payload}; + // case HYDRATE: + // // Ignoring initial state in subtree + // if (action.payload.app === 'init') { + // delete action.payload.app; + // } + // // Ignoring initial state in subtree + // if (action.payload.page === 'init') { + // delete action.payload.page; + // } + // return {...state, ...action.payload}; case 'APP': return {...state, app: action.payload}; case 'PAGE': return {...state, page: action.payload}; + case 'PROMISE_FULFILLED': // async + return {...state, promise: action.payload}; + case 'PROMISE_APP_FULFILLED': // async + return {...state, promiseApp: action.payload}; default: return state; } diff --git a/packages/demo/src/components/store.tsx b/packages/demo/src/components/store.tsx index c36c698..3578571 100644 --- a/packages/demo/src/components/store.tsx +++ b/packages/demo/src/components/store.tsx @@ -1,10 +1,14 @@ import {createStore, applyMiddleware, Store} from 'redux'; import logger from 'redux-logger'; -import {createWrapper, Context} from 'next-redux-wrapper'; +import promiseMiddleware from 'redux-promise-middleware'; +import {createWrapper, MakeStore} from 'next-redux-wrapper'; import reducer, {State} from './reducer'; -export const makeStore = (context: Context) => { - const store = createStore(reducer, applyMiddleware(logger)); +export const makeStore: MakeStore = ({context, reduxWrapperMiddleware}) => { + const store = createStore( + reducer, + applyMiddleware(...[promiseMiddleware, process.browser ? logger : null, reduxWrapperMiddleware].filter(Boolean)), + ); if ((module as any).hot) { (module as any).hot.accept('./reducer', () => { diff --git a/packages/demo/src/pages/_app.tsx b/packages/demo/src/pages/_app.tsx index a602a96..0299e37 100644 --- a/packages/demo/src/pages/_app.tsx +++ b/packages/demo/src/pages/_app.tsx @@ -1,26 +1,33 @@ import React from 'react'; import App, {AppProps} from 'next/app'; +import {Provider} from 'react-redux'; import {wrapper} from '../components/store'; -class MyApp extends React.Component { - public static getInitialProps = wrapper.getInitialAppProps(store => async context => { - // Keep in mind that this will be called twice on server, one for page and second for error page - store.dispatch({type: 'APP', payload: 'was set in _app'}); +export default function MyApp({Component, pageProps}: AppProps) { + const store = wrapper.useStore(); + return ( + + + + ); +} - return { - pageProps: { - // https://nextjs.org/docs/advanced-features/custom-app#caveats - ...(await App.getInitialProps(context)).pageProps, - // Some custom thing for all pages - appProp: context.ctx.pathname, - }, - }; - }); +//FIXME This is not a recommended approach, only used here for demo purposes +(MyApp as any).getInitialProps = wrapper.getInitialAppProps(store => async context => { + // Keep in mind that this will be called twice on server, one for page and second for error page + store.dispatch({type: 'APP', payload: 'was set in _app'}); - public render() { - const {Component, pageProps} = this.props; - return ; - } -} + await store.dispatch({ + type: 'PROMISE_APP', + payload: new Promise(res => setTimeout(() => res('bar'), 1)), + }); -export default wrapper.withRedux(MyApp); + return { + pageProps: { + // https://nextjs.org/docs/advanced-features/custom-app#caveats + ...(await App.getInitialProps(context as any)).pageProps, + // Some custom thing for all pages + appProp: context.ctx.pathname, + }, + }; +}); diff --git a/packages/demo/src/pages/index.tsx b/packages/demo/src/pages/index.tsx index 73b9820..41f1ddf 100644 --- a/packages/demo/src/pages/index.tsx +++ b/packages/demo/src/pages/index.tsx @@ -1,61 +1,60 @@ import React from 'react'; import Link from 'next/link'; -import {connect} from 'react-redux'; -import {NextPageContext} from 'next'; +import {useSelector} from 'react-redux'; import {State} from '../components/reducer'; +import {wrapper} from '../components/store'; +import {NextPage} from 'next'; export interface PageProps extends State { pageProp: string; appProp: string; } -class Index extends React.Component { - // note that since _app is wrapped no need to wrap page - public static async getInitialProps({store, pathname, query, req}: NextPageContext) { - console.log('2. Page.getInitialProps uses the store to dispatch things', { - pathname, - query, - }); - - if (req) { - // All async actions must be await'ed - await store.dispatch({type: 'PAGE', payload: 'server'}); - - // Some custom thing for this particular page - return {pageProp: 'server'}; - } - - // await is not needed if action is synchronous - store.dispatch({type: 'PAGE', payload: 'client'}); +const Page: NextPage = function ({pageProp, appProp, ...props}) { + wrapper.useHydration(props); + const {app, page, promise, promiseApp} = useSelector(state => state); + return ( +
+

+ Try to navigate to another page and return back here to see how getInitialProps will be used on client side. +

+ +
{JSON.stringify({pageProp, appProp, app, page, promise, promiseApp}, null, 2)}
+ + Navigate + {' | '} + Navigate to pageProps + {' | '} + Navigate to pageProps2 + {' | '} + Navigate to server + {' | '} + Navigate to static + {' | '} + Navigate to error +
+ ); +}; + +(Page as any).getInitialProps = wrapper.getInitialPageProps(store => async ({pathname, query, req}) => { + console.log('2. Page.getInitialProps uses the store to dispatch things', { + pathname, + query, + }); + + if (req) { + // All async actions must be awaited + await store.dispatch({type: 'PAGE', payload: 'server'}); // Some custom thing for this particular page - return {pageProp: 'client'}; + return {pageProp: 'server'}; } - public render() { - // console.log('5. Page.render'); - const {pageProp, appProp, app, page} = this.props; - return ( -
-

- Try to navigate to another page and return back here to see how getInitialProps will be used on client - side. -

- -
{JSON.stringify({pageProp, appProp, app, page}, null, 2)}
- - Navigate - {' | '} - Navigate to pageProps - {' | '} - Navigate to pageProps2 - {' | '} - Navigate to static - {' | '} - Navigate to error -
- ); - } -} + // await is not needed if action is synchronous + store.dispatch({type: 'PAGE', payload: 'client'}); + + // Some custom thing for this particular page + return {pageProp: 'client'}; +}); -export default connect(state => state)(Index); +export default Page; diff --git a/packages/demo/src/pages/pageProps.tsx b/packages/demo/src/pages/pageProps.tsx index 416d277..c57cbb4 100644 --- a/packages/demo/src/pages/pageProps.tsx +++ b/packages/demo/src/pages/pageProps.tsx @@ -5,13 +5,14 @@ import {State} from '../components/reducer'; import {wrapper} from '../components/store'; import {useSelector} from 'react-redux'; -const PropsPage: NextPage = props => { - const {page} = useSelector(state => state); +const PropsPage: NextPage = ({prop, appProp, ...props}) => { + wrapper.useHydration(props); + const {page, app} = useSelector(state => state); return (

Using Next.js default prop in a wrapped component.

-
{JSON.stringify({props, page})}
+
{JSON.stringify({prop, appProp, page, app})}
diff --git a/packages/demo/src/pages/pageProps2.tsx b/packages/demo/src/pages/pageProps2.tsx index 6c5896c..b5d0b3f 100644 --- a/packages/demo/src/pages/pageProps2.tsx +++ b/packages/demo/src/pages/pageProps2.tsx @@ -1,12 +1,14 @@ import React from 'react'; import {NextPage} from 'next'; import Link from 'next/link'; +import {wrapper} from '../components/store'; -const PropsPage2: NextPage = props => { +const PropsPage2: NextPage = ({prop, appProp, ...props}) => { + wrapper.useHydration(props); return (

Using Next.js default prop in a wrapped component.

-
{JSON.stringify(props)}
+
{JSON.stringify({prop, appProp})}
@@ -14,8 +16,6 @@ const PropsPage2: NextPage = props => { ); }; -PropsPage2.getInitialProps = () => ({ - prop: 'bar', -}); +PropsPage2.getInitialProps = () => ({prop: 'bar'}); export default PropsPage2; diff --git a/packages/demo/src/pages/server.tsx b/packages/demo/src/pages/server.tsx index 9273938..49cb6d6 100644 --- a/packages/demo/src/pages/server.tsx +++ b/packages/demo/src/pages/server.tsx @@ -10,7 +10,9 @@ interface OtherProps { appProp: string; } -const Server: NextPage = ({appProp, getServerSideProp}) => { +const Server: NextPage = ({appProp, getServerSideProp, ...props}) => { + const {hydrating} = wrapper.useHydration(props); + console.log('render server', hydrating); const {app, page} = useSelector(state => state); return (
diff --git a/packages/demo/src/pages/static.tsx b/packages/demo/src/pages/static.tsx index 181abb7..13eabc2 100644 --- a/packages/demo/src/pages/static.tsx +++ b/packages/demo/src/pages/static.tsx @@ -10,13 +10,14 @@ interface OtherProps { appProp: string; } -const Static: NextPage = ({appProp, getStaticProp}) => { - const {app, page} = useSelector(state => state); +const Static: NextPage = ({appProp, getStaticProp, ...props}) => { + wrapper.useHydration(props); + const {app, page, promise, promiseApp} = useSelector(state => state); return (

Page has access to store even though it does not dispatch anything itself

-
{JSON.stringify({app, page, getStaticProp, appProp}, null, 2)}
+
{JSON.stringify({app, page, promise, promiseApp, getStaticProp, appProp}, null, 2)}