Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Remove double hydration #499

Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 15 additions & 10 deletions packages/wrapper/src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import App, {AppContext, AppInitialProps} from 'next/app';
import { useRouter } from 'next/router';
import {useRouter} from 'next/router';
import React, {useLayoutEffect, useMemo, useRef} from 'react';
import {Provider} from 'react-redux';
import {Store} from 'redux';
Expand Down Expand Up @@ -32,6 +32,8 @@ export const HYDRATE = '__NEXT_REDUX_WRAPPER_HYDRATE__';

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

const useBrowserLayoutEffect = getIsServer() ? () => undefined : useLayoutEffect;

const getDeserializedState = <S extends Store>(initialState: any, {deserializeState}: Config<S> = {}) =>
deserializeState ? deserializeState(initialState) : initialState;

Expand Down Expand Up @@ -163,25 +165,28 @@ export const createWrapper = <S extends Store>(makeStore: MakeStore<S>, config:
};

const useHybridHydrate = (store: S, state: any) => {
const firstHydrate = useRef(true);
const prevRoute = useRef<string>('');

const { asPath } = useRouter();
const {asPath} = useRouter();

const newPage = prevRoute.current !== asPath;

prevRoute.current = asPath;

// synchronous for server or first time render
useMemo(() => {
if (newPage) {
if (newPage && firstHydrate) {
hydrate(store, state);
}
}, [store, state, newPage]);

// asynchronous for client subsequent navigation
useLayoutEffect(() => {
useBrowserLayoutEffect(() => {
// FIXME Here we assume that if path has not changed, the component used to render the path has not changed either, so we can hydrate asynchronously
if (!newPage) {
if (firstHydrate) {
firstHydrate.current = false;
} else if (!newPage) {
hydrate(store, state);
}
}, [store, state, newPage]);
Expand All @@ -201,8 +206,7 @@ export const createWrapper = <S extends Store>(makeStore: MakeStore<S>, config:

const store = useMemo<S>(() => initStore<S>({makeStore}), []);

useHybridHydrate(store, initialState);
useHybridHydrate(store, initialStateFromGSPorGSSR);
useHybridHydrate(store, initialStateFromGSPorGSSR ?? initialState ?? null);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both are needed, because _app may have getInitialProps and page may have getServerSideProps. This have to result in 2 hydrates.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One should be sufficient.

In the callback of wrapper.getInitialAppProps you dispatch a number actions filling your store.
After that inside wrapper.getServerSideProps you can dispatch a next set of actions, you can even use the store.getState() together with some selectors to base logic on for further dispatches. These will all get picked up by the store instance and be reduced after the getInitialAppProps resulting reduces.

After all of this (getInitialProps and sequentially getServerSideProps) in your _app.tsx JSX you call wrapper.useWrappedStore which will perform the hydration. At this point your pageProps.initialState will also have the initialState (if not altered by the GSSP dispatches), it's basically includes both.

If you don't render a GSSP page, you will fallback to the initialState from _app.tsx' getInitialProps

You can check and see all demo app packages still work as expected with this change.

Copy link
Contributor Author

@voinik voinik Nov 4, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dug around trying to figure out exactly what the data flow is so here are my findings, which corroborate @karaoak's comment. I wrote this as detailed as I did mostly for myself so that I have a reference to fall back on, but hopefully it explains the idea well!

The data flow roughly goes like this:

  1. getInitialProps (GIP) ->
  2. getServerSideProps (GSSP) ->
  3. data passed into the App component as an object containing a pageProps key, and in our case a initialState key, and some other Next.js stuff.

Let's say we refresh the page. And let's say we have 2 slices in our store state, one "page" slice for our page, one "generic" slice that gets set on each page through GIP.

  1. When we run GIP, we run the dispatches synchronously on the server. This changes the state in the store. We then return the props as "pageProps", and the store state as "initialState" (I JSON stringified an example object):
{
  "pageProps": {
    "foo": "bar"
  },
  "initialState": {
    "generic": {
      "data": {
        "appName": "Test App" // <-- set in GIP dispatches
      }
    },
    "page": {
      "data": null // <-- not set yet
    }
  }
}

We see the generic slice is set now, and the page slice isn't yet, because GSSP hasn't run yet. We also have the initial props now.

  1. Now we get to GSSP. When we run it, we run the dispatches in it synchronously as well. This changes the state in the store again. We then return the props with inside it an "initialState" key which is the new store state. This "initialState" has the store data from the GIP dispatches in it, as well as the new data that was just dispatched:
{
  "props": {
    "name": "foo",
    "initialState": { // <-- newest store state
      "generic": {
        "data": {
          "appName": "Test App" // <-- set in GIP dispatches
        }
      },
      "page": {
        "data": {
          "fullName": "FOO" // <-- is set now
        }
      }
    }
  }
}

We see the generic slice is still set, and the page slice has now also been set, because both GIP and GSSP have run. We also have the page props.

  1. Finally, Next.js does some magic before sending off the data to the App component. It takes the object that GIP returned, takes its "pageProps" key and spreads the "props" from GSSP into it. So the "props" data from GSSP gets copied into the "pageProps" from GIP. However, the "initialState" key from the GIP is left untouched. And also: the "initialState" key from GSSP (with the newest data!) is now inside of "pageProps". So the result of the data examples above is this:
{
  "pageProps": {
    "foo": "bar", // <-- from GSSP
    "name": "foo", // <-- from GIP
    "initialState": { // <-- from GSSP
      "generic": {
        "data": {
          "appName": "Test App", // <-- set during GIP
        }
      },
      "page": {
        "data": {
          "fullName": "FOO" // <-- set during GSSP
        }
      }
    }
  },
  "initialState": { // <-- the old data from GIP
    "generic": {
      "data": {
        "appName": "Test App", // <-- set during GIP
      }
    },
    "page": {
      "data": null // <-- not set because this is old data, from GIP, from before the GSSP dispatches
    }
  }
}

So "pageProps" now contains the page props, and the store state that contains all the dispatches. We don't need the "initialState" version that's outside of "pageProps", we need the one inside of "pageProps" (so pageProps.initialState).

Now if we don't have GSSP or getStaticProps (GSP) for a page, then it's another story. In that case no extra data is merged into the GIP data, so "pageProps" will not contain an "initialState" key. In this case we do need the "initialState" key that's outside of "pageProps".

So this means that if pageProps.initialState exists, we want that store state. If it doesn't exist, then we want the initialProps store state. And if that doesn't exist we should use null.

In your code you assign pageProps.initialState to a variable called initialStateFromGSPorGSSR and the "initialState" outside "pageProps" is simply called initialState in your code.

So @karaoak's useHybridHydrate(store, initialStateFromGSPorGSSR ?? initialState ?? null); line is in fact correct.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very well done!


let resultProps: any = props;

Expand Down Expand Up @@ -266,6 +270,7 @@ export const createWrapper = <S extends Store>(makeStore: MakeStore<S>, config:
};

// Legacy
// eslint-disable-next-line import/no-anonymous-default-export
export default <S extends Store>(makeStore: MakeStore<S>, config: Config<S> = {}) => {
console.warn('/!\\ You are using legacy implementaion. Please update your code: use createWrapper() and wrapper.withRedux().');
return createWrapper(makeStore, config).withRedux;
Expand All @@ -289,11 +294,11 @@ type GetInitialPageProps<P> = NextComponentType<NextPageContext, any, P>['getIni
//FIXME Could be typeof App.getInitialProps & appGetInitialProps (not exported), see https://github.com/kirill-konshin/next-redux-wrapper/issues/412
type GetInitialAppProps<P> = ({Component, ctx}: AppContext) => Promise<AppInitialProps & {pageProps: P}>;

export type GetStaticPropsCallback<S extends Store, P> = (store: S) => GetStaticProps<P>;
export type GetServerSidePropsCallback<S extends Store, P> = (store: S) => GetServerSideProps<P>;
export type GetStaticPropsCallback<S extends Store, P extends {[key: string]: any}> = (store: S) => GetStaticProps<P>;
export type GetServerSidePropsCallback<S extends Store, P extends {[key: string]: any}> = (store: S) => GetServerSideProps<P>;
export type PageCallback<S extends Store, P> = (store: S) => GetInitialPageProps<P>;
export type AppCallback<S extends Store, P> = (store: S) => GetInitialAppProps<P>;
export type Callback<S extends Store, P> =
export type Callback<S extends Store, P extends {[key: string]: any}> =
| GetStaticPropsCallback<S, P>
| GetServerSidePropsCallback<S, P>
| PageCallback<S, P>
Expand Down