microsoft / redux-dynamic-modules

Modularize Redux by dynamically loading reducers and middlewares.
https://redux-dynamic-modules.js.org
MIT License
1.07k stars 116 forks source link

Strategies for hydrating a store from SSR #47

Open exogen opened 5 years ago

exogen commented 5 years ago

Hi there, I'm experimenting with this package and have been pleasantly surprised so far. Good job!

I wonder if it would be worthwhile to add some documentation about possible solutions to deal with server-side rendering. A typical server-side rendered Redux setup looks like this:

  1. On each request to the server, a fresh Redux store is created for that request.
  2. When generating the response body, the Redux state for that request is serialized into the HTML.
  3. When the client hydrates, a new global client-side Redux store is created and preloaded with the serialized state.

Maybe you can see the problem: on the server, some arbitrary set of dynamic Redux modules may have been loaded. On the client, we get the state those modules created, but we have a new store instance without any modules actually loaded. Besides inferring it from what state exists in the store, there's no indication of which modules need to be loaded to deal with that state. The set of loaded modules is effectively part of the Redux store's state, but one that hasn't been serialized from server to client.

You might think DynamicModuleLoader is a good enough solution for this, since it should result in the same modules being loaded on client and server if the same tree is rendered (like it should). The issue is that often, data needs to be fetched ahead of time (e.g. with Redux) before a component tree is even created/decided upon, since (until Suspense arrives) there is no way to "wait" for data during SSR. So, frameworks invent new lifecycle hooks to do this fetching, like getInitialProps in Next.js. In this hook, you'd need to use Redux, fire actions, etc., but any module you'd need would have to be added imperatively with addModules.

The set of loaded modules must be communicated in a serializable way, like an array of strings. This could be the module IDs (you'd need to keep a global mapping of ID → module in order to actually load them, which might defeat the purpose of splitting everything up in the first place), or perhaps the module path (so it can be dynamically imported).

That leaves the issue of initialActions: AFAIK, if the initial actions were already fired on the server, there's no way to tell the client-side store to skip those actions. Some actions, depending on their side effects, might be necessary to fire on both the client and server, while others you'd want to skip if the server already fired them. This could be handled with some bookkeeping in the module's own store state, like bootstrapped: true or something, or maybe you could dynamically modify the module to exclude initialActions when necessary on the client...

Anyway, I wonder if anyone else has thought about this, and what your approach is.

navneet-g commented 5 years ago

@exogen Thanks for the question. I don't have any experience with Server side rendering. @Stoope has used the library with SSR, see #31 . Will following work 1) Create a Module registry, which maps Ids to IModule objects. 2) While serializing the store serialize list of module ids loaded. 3) When creating a store at client use the data from 2, get the modules from the registry and pas it to createStore or DynamicModuleLoader

exogen commented 5 years ago

Update on how this is going for me so far...

Here's a diagram particular to Next.js with how this roughly needs to work:

redux-dynamic

It's mostly working fine. The main issue I have right now is that:

This is merely a warning and doesn't technically break anything, but I'd prefer it not to happen. The issue is that the ModuleAdded action is being fired, and Redux is seeing some preloaded state (from SSR) that isn't associated with any current reducer (since, in order to get access to the remove handle, I needed to add the modules myself rather than in initialModules).

exogen commented 5 years ago

My immediate problem could be solved by making this line:

https://github.com/Microsoft/redux-dynamic-modules/blob/405a8f64c182e15873f37131db5a757090c77455/packages/redux-dynamic-modules/src/ModuleStore.ts#L177

something like:

store.initialModules = store.addModules(initialModules);

So that one could then do:

store.initialModules.remove();

But I won't PR that just yet, maybe you've got some grander design in mind. :)

Considering that ModuleStore simply calls addModules right before it returns anyway, I'm actually not sure why doing it myself results in the Redux warning, but letting ModuleStore do it doesn't...

Edit: Now when doing addModules immediately after createStore to get a remove handle, I'm not seeing that Redux warning anymore, so... 🤷‍♂️

navneet-g commented 5 years ago

Can you do following instead? 1) Have initialActions in the modules added via DynamicModuleLoader 2) The action can have payload that is read the json data sent from the server

navneet-g commented 5 years ago

@exogen did the above work for you?

exogen commented 5 years ago

@navneet-g I don't believe that works in the grand scheme, but I'm still experimenting and figuring out a workable solution.

It might solve the initial hydration (haven't checked yet) although I believe it might throw that Redux "unexpected key" warning. The reason I can't use it in the general case is due to subsequent client-side navigations (the Next Page part of the diagram above). In that case, both getInitialProps and render are fired on the client-side – not getInitialProps on the server and render on both server+client like the SSR case. So DynamicModuleLoader can't really be used since the module needs to be added in getInitialProps (to handle any Redux actions fired there to load data). If DynamicModuleLoader is used, then it's simply adding + removing another reference count to an already-loaded module, and we have a reference leak (added in getInitialProps but never removed). If it's added in getInitialProps, removed at the end of getInitialProps, and then loaded again via DynamicModuleLoader, then the state is lost in between getInitialProps and render since the reference count would drop to zero.

That's why the diagram points out the desired solution: the module is added in getInitialProps (before the component is even constructed) and not removed until the component is unmounted.

jacintorodrigues commented 5 years ago

@exogen can you provide an example of how you be able to make it work with Next.js?

neeharv commented 5 years ago

Hey folks, this is turning into a blocker for us as well.

Can you do following instead?

  1. Have initialActions in the modules added via DynamicModuleLoader
  2. The action can have payload that is read the json data sent from the server

The problem with this approach is that lazy loaded code in SSR is synchronously loaded, which means initial actions fire for the loaded modules. But since this action cannot read the initial JSON sent from the server (while it's running on the server itself), we would then have to make write the action in a way where its a no-op when running on the server, but load the state from the JSON on the client. This significantly increases boilerplate and indirection.

I'm not sure what is the best approach here, but if some sort of hydrator prop could be passed into the DynamicModuleLoader react component, it could then do the simple setting of the reducer's slice of state by calling some pre-defined fn. If that approach feels worth exploring, happy to figure a PR

djsilcock commented 5 years ago

That's why the diagram points out the desired solution: the module is added in getInitialProps (before the component is even constructed) and not removed until the component is unmounted.

I haven't had chance to actually test this yet, but it might be possible to remove the modules in componentWillUnmount using a pattern such as :

 ... 
 getInitialProps(ctx) {
    //may be serverside or clientside
    const removeHandle=store.addModules(modules).remove
    if(typeof window !=='undefined') window.removeHandle=removeHandle
   ... 
} 
componentWillUnmount() {
    //only ever clientside
    window.removeHandle && window.removeHandle()
} 

// or alternatively

getInitialProps(ctx) {
    //may be serverside or clientside
    const removeHandle=store.addModules(modules).remove
    ...
    return {...otherprops,removeHandle}
    //as functions cannot be JSON serialized then the handle will only be sent if this method is executed clientside
} 
componentWillUnmount() {

    this.props.removeHandle && this. props.removeHandle()
} 

This is merely a warning and doesn't technically break anything, but I'd prefer it not to happen. The issue is that the ModuleAdded action is being fired, and Redux is seeing some preloaded state (from SSR) that isn't associated with any current reducer

I'm not sure that this is necessarily 'just a warning' as if any of the reducers alter the state in response to the ModuleAdded action then the resulting state is formed only from the outputs of the current reducers. If however all the reducers return reducer(oldstate) ===oldstate then the combined reducer returns the original state object unchanged - ie with the preloaded but not yet used state retained. This means that if any of the dispatched actions (the ModuleAdded action or any initial actions) change the state in any way, the state for all the not-yet-installed modules will be discarded. Possible ways to rehydrate the store from SSR then might include retrieving state on initial reducer seeding if it has been deleted:

function myReducer(oldState,action){
    if (oldState===undefined && typeof window !=='undefined' && window.__STATE_FROM_SERVER) return window.__STATE_FROM_SERVER.myReducer
    ...
    }

Alternatively code something like this into the core reducer implementation or the <DynamicModuleLoader/> component (as mentioned above) rather than require boilerplate at the top of every reducer

Happy to contribute a PR if wanted

flaviouk commented 4 years ago

Anyone got this to work properly with nextjs?

fostyfost commented 4 years ago

Hey. Take a look at my example of using NEXT with dynamic modules. https://github.com/fostyfost/next-with-redux-dynamic-modules

hellokunji commented 3 years ago

@fostyfost : This is a great example, Thanks for that. There is problem though, your example seems to be working fine with next's getInitialProps but it doesn't work when we use getServerSideProps and getStaticProps

when i converted your users.tsx code

from

UsersPage.getInitialProps = context => {
  if (!isUsersLoaded(context.store.getState())) {
    context.store.dispatch(UsersPublicAction.loadUsers())
  }

  return { title: 'Users page' }
}

export default withDynamicModuleLoader(UsersPage, [getUsersModule()])

to

UsersPage.getServerSideProps = context => {
  if (!isUsersLoaded(context.store.getState())) {
    context.store.dispatch(UsersPublicAction.loadUsers())
  }

  return { title: 'Users page' }
}

export default withDynamicModuleLoader(UsersPage, [getUsersModule()])

it shows blank page now only

fostyfost commented 3 years ago

@hellokunji hi! Thank you for your feedback! Currently my example doesn't support modern Next.js GS(S)P-feature but I'm working on it. Also I want to distribute this feature as a standalone library.

hellokunji commented 3 years ago

@hellokunji hi! Thank you for your feedback! Currently my example doesn't support modern Next.js GS(S)P-feature but I'm working on it. Also I want to distribute this feature as a standalone library.

@fostyfost No problem!, I will be following and waiting for the library updates. 👍

djsilcock commented 3 years ago

@fostyfost : This is a great example, Thanks for that. There is problem though, your example seems to be working fine with next's getInitialProps but it doesn't work when we use getServerSideProps and getStaticProps

The object returned from getServerSideProps is different to the getInitialProps Try:

UsersPage.getServerSideProps = context => {
 if (!isUsersLoaded(context.store.getState())) {
context.store.dispatch(UsersPublicAction.loadUsers())
}
  return {props:{ title: 'Users page' }}
 }

export default withDynamicModuleLoader(UsersPage, [getUsersModule()])
hellokunji commented 3 years ago

@fostyfost : This is a great example, Thanks for that. There is problem though, your example seems to be working fine with next's getInitialProps but it doesn't work when we use getServerSideProps and getStaticProps

The object returned from getServerSideProps is different to the getInitialProps Try:

UsersPage.getServerSideProps = context => {
if (!isUsersLoaded(context.store.getState())) {
context.store.dispatch(UsersPublicAction.loadUsers())
}
 return {props:{ title: 'Users page' }}
}

export default withDynamicModuleLoader(UsersPage, [getUsersModule()])

@djsilcock Thanks for the reply, it still doesn't work.

fostyfost commented 2 years ago

@hellokunji hi! Please, try this library is your issue is still actual. https://github.com/fostyfost/redux-eggs