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

How to conditionally include packages on client but not server (and vice-versa)? #219

Closed
evantahler opened this issue Nov 7, 2016 · 31 comments

Comments

@evantahler
Copy link
Contributor

Say you wanted to render some maps in your Next application. You chose the fairly popular react-leaflet project, which takes the very popular leaflet library... and React-izes it.

It turns out that Leaflet is in fact not ready for an isomorphic application, as it has things like window and navigator all over its codebase. This is turn causes Next to crash with a sad ReferenceError: window is not defined.

You can't use conditional logic in your map component because include statements need to happen at the top of the file, and at boot. You can change from include to require (and require conditionally), but that seems like a bad idea, and Next throws a warning (below). You can block rendering a map on the server, but not the inclusion of the react-leaflet => react libraries.

The warning Next throws when using a conditional required:

Warning: React attempted to reuse markup in a container but the checksum was invalid. This generally means that you are using server rendering and the markup generated on the server was not what the client was expecting. React injected new markup to compensate which works but you have lost many of the benefits of server rendering. Instead, figure out why the markup being generated is different on the client or server:
 (client) react-empty: 27 --><!-- react-empty: 28 
 (server) react-empty: 27 --><p data-reactid="28">

It's fairly easy to check if you are running on the sever or client:

export default function isNode(){
  // http://stackoverflow.com/questions/4224606/how-to-check-whether-a-script-is-running-under-node-js
  return (typeof process !== 'undefined') && (process.release) && (process.release.name === 'node');
};

How should we handle something like this? I could imagine a try/catch scenario when rendering on the server looking for specifically window, navigator, etc and rendering a small ./pages/_unrenderable.js in those components.... but that seems... hacky.

@mikecardwell
Copy link

Conditional require is probably the way to go. That's not a Next error, that's a React error. React warns if you do an initial render to an element that already contains react component markup inside it which doesn't match exactly. You want to make sure that the first render on both the server and the client creates the exact same structure. So if you want to render something like a map on the client but not the server, don't do it until after the initial render. You can do this by setting some state in componentDidMount which causes a re-render with the actual content you want displayed (componentDidMount only gets run on the client)

let mapLib;

export default class SomePage extends React.Component {
  constructor () {
    super();
    this.state = { showMap: false };
  }
  componentDidMount () {
    mapLib = require('some-map-library');
    this.setState({ showMap: true });
  }
  render () {
    return (
      <div>
        {
          this.state.showMap ? (some sort of map thing) : "Loading map"
        }
      </div>
    );
  }
}

On the above example, the first thing rendered on both the server and the client is <div>Loading map</div>

P.S. The above is untested and I am not a Next developer, just a random guy trying to use it.

@evantahler
Copy link
Contributor Author

That's some good hackery! Thanks!

... I'm going to leave this issue open in case there's a better way, but this gets me where I need to go!

@sedubois
Copy link
Contributor

sedubois commented Nov 8, 2016

@mikecardwell using setState in componentDidMount triggers a second render and can cause prop/layout thrashing (I noticed thanks to the eslint no-did-mount-set-state rule).

@frol
Copy link
Contributor

frol commented Nov 8, 2016

@sedubois That is quite obvious, isn't it? The alternative is to either ignore React warnings or hack React to drop the checksums validations (I think, the minified version of React drops these checks for you)... The choice is yours.

@mikecardwell
Copy link

It does trigger a second render. That's why this fixes the problem being discussed. "prop/layout thrashing" sounds much worse than it is. It makes sense to generally avoid calling setState in componentDidMount if you can do it elsewhere, e.g. componentWillMount, but in this case, we want to trigger a second render, and the first render is so tiny that it's not going to hit performance.

He could even stick this at the top of his render function:

if (!this.state.showMap) return null;

I don't think we'll get much "thrashing" from rendering nothing to the page once.

@sedubois
Copy link
Contributor

sedubois commented Nov 8, 2016

Yes it's obvious, but was less obvious that it's a "bad practice" as flagged by the eslint rule. Would just have been nice to render properly in one go. But yes, not a big deal, it's just hacky as mentioned.

@mikecardwell
Copy link

I don't think there's anything that could be done in the next framework to avoid this "hack". It's entirely a React issue. I've had to do stuff like this in the past when it comes to dates. Imagine the following component:

const component = () => <p>{ Date.now() }</p>;

That will trigger this exact same problem. The initial render on the server will show one date and the initial render on the client will almost certainly show a different one unless the server and client clocks match to the millisecond, taking latency between server/client render into account.

@arunoda
Copy link
Contributor

arunoda commented Nov 12, 2016

@evantahler You could use react-no-ssr:

import React from 'react';
import NoSSR from 'react-no-ssr';
import Comments from './comments.jsx';

const MyPage = () => (
  <div>
    <h2>My Blog Post</h2>
    <hr />
    <NoSSR>
      <Comments />
    </NoSSR>
  </div>
);

In the above example, comment will only render on the client.

@evantahler
Copy link
Contributor Author

@arunoda again, it's not a problem of controlling rendering, it's a problem of import

@arunoda
Copy link
Contributor

arunoda commented Nov 12, 2016

@evantahler Okay. got it.
Then I agree with @mikecardwell solution and it's totally fine to have a another render on the client.

With SSR, it won't be another render.
First rendering of the component will take care by SSR and in the client it simply use the code comes with SSR.
Then it'll do another render to load the client only stuff.

For me this is totally how it should be done.
I don't see an anti-pattern here.

@nkzawa
Copy link
Contributor

nkzawa commented Jan 2, 2017

I think we can close this now.

Additionally, if you'd like to include a module only on server, you can conditionally require and exclude it from a bundle file for client like the following (v2 or later).

// pages/index.js
const isServer = typeof window === 'undefined'
const foo = isServer ? require('foo') : null
// next.config.js
module.exports = {
  webpack (config) {
    config.externals = config.externals || {}
    config.externals.foo = 'foo'
    return config
  }
}

@kaelhem
Copy link

kaelhem commented Feb 10, 2017

I'm facing a similar issue as @evantahler with react-leaflet.

The fix proposal (mounting map only on client side) seems a good approach, but I cannot manage to make it work.

The error is :
Uncaught (in promise) TypeError: Cannot read property 'removeLayer' of undefined. at _fn.componentWillUnmount (eval at evalScript (eval-script.js?cb1dd48:22), <anonymous>:2255:24) at ReactCompositeComponent.js?7b94502:409 at measureLifeCyclePerf (ReactCompositeComponent.js?7b94502:75) at ReactCompositeComponentWrapper.unmountComponent (ReactCompositeComponent.js?7b94502:408) at Object.unmountComponent (ReactReconciler.js?cb1dd48:79) at Object.unmountChildren (ReactChildReconciler.js?7b94502:146) at ReactDOMComponent.unmountChildren (ReactMultiChild.js?e45aa37:373) at ReactDOMComponent.unmountComponent (ReactDOMComponent.js?7b94502:980) at Object.unmountComponent (ReactReconciler.js?cb1dd48:79) at ReactCompositeComponentWrapper.unmountComponent (ReactCompositeComponent.js?7b94502:418)

Did you encounter this error guys?
Any help will be greatly appreciated!

@mikecardwell
Copy link

I could probably diagnose your problem if I saw your code. Going on the error message alone, I haven't a clue.

@kaelhem
Copy link

kaelhem commented Feb 11, 2017

yes of course!

the revelant part is here :

let Map, TileLayer

export default class LeafletWrapper extends React.Component {
  constructor () {
    super()
    this.state = { showMap: false }
  }

  componentDidMount() {
    let RL = require('react-leaflet')
    Map = RL.Map
    TileLayer = RL.TileLayer
    this.setState({ showMap: true })
  }

  render () {
    return this.state.showMap ? (
      <Map
        center={[48.85692, 2.35268]}
        zoom={13}
      >
        <TileLayer
          attribution='&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
          url='http://{s}.tile.osm.org/{z}/{x}/{y}.png'
        />
      </Map>  
    ) : <div>Loading map</div>
  }
}

and an extract of the dependencies in my package.json:

{
  "dependencies": {
    "next": "2.0.0-beta.24",
    "react": "^15.4.2",
    "react-dom": "^15.4.2",
    "leaflet": "^1.0.3",
    "react-leaflet": "^1.1.0"
    ...
  }
}

thanks for interest!

@mikecardwell
Copy link

mikecardwell commented Feb 11, 2017

I just created a basic next app using your code from above and it worked for me with no errors in the console. I then added an additional buitton to allow me to toggle the showMap state manually so I could make sure everything worked fine as the map was mounted/unmounted, and there were no issues at all. Any chance you could make a minimal example of this problem happening which I could install on my local system and see the error myself?

[edit] I wasn't using Next 2 actually. Brb

[edit 2] Hmm. I get the same issue with Next 2. I'm stumped. I haven't actually looked into Next 2 previously though so presumably something has changed which I'm not aware of. One interesting thing to note is that if you keep the Map, but lose the TileLayer, you don't get the error message. I'm not suggesting that as a solution, just a data point ;)

@timneutkens
Copy link
Member

@kaelhem Not related. But Map is an es6 feature, so it might be good to not use it as a variable name. https://developer.mozilla.org/nl/docs/Web/JavaScript/Reference/Global_Objects/Map

@kaelhem
Copy link

kaelhem commented Feb 11, 2017

You're totally right. But this is not the problem here.

@kaelhem
Copy link

kaelhem commented Feb 11, 2017

@mikecardwell ha, this is a good catch. I will have look during this weekend...

@kaelhem
Copy link

kaelhem commented Feb 14, 2017

for interested people, I've open a ticket on stackoverflow where I describe a workaround. It seems like a bug in react-leaflet.

@jsnelgro
Copy link

jsnelgro commented Apr 3, 2017

The solution offered by @mikecardwell works, but mixing the require syntax and import syntax feels like a hacky and short-lived solution. Maybe this issue should be reopened in order to prompt next.js to implement a better solution?

@ubershmekel
Copy link

Shopify/buy-button-js#381 is the same issue. Thanks for the require solution.

@chemzqm
Copy link

chemzqm commented Sep 15, 2017

The require solution works well, but it looks hacky, there's should be better solution.

@Florian-R
Copy link

@chemzqm In the meantime, support for dynamic import has been merged. Dynamic import without SSR could help here I think.

@pixelomo
Copy link

pixelomo commented Oct 18, 2017

Like your solution @mikecardwell

However, now I'm getting this error: Do not use setState in componentDidMount

Edit
To resolve this I had to update my eslint settings within node_modules/eslint-airbnb-config/rules/react.js

Change
'react/no-did-mount-set-state': [2, 'allow-in-func'],

To
'react/no-did-mount-set-state': 0

@emehmet
Copy link

emehmet commented Feb 26, 2018

Hi everyone,
I have come up with an approach to this issue.

I used the react-leaflet code normally in my use_map.js file I created. And I imported this file in another file with next/dynamic and its nossr option and I called it in render function. And it's worked for me :)

const DynamicComponentWithNoSSR = dynamic(import('../components/hello3'), {
ssr: false
})

@yzfdjzwl
Copy link

yzfdjzwl commented May 6, 2018

@mikecardwell You save my life!

@vjpr
Copy link

vjpr commented May 7, 2018

Be aware of silenced errors in nossr dynamic components: #2898

@julkue
Copy link

julkue commented Jun 1, 2018

@arunoda

@evantahler You could use react-no-ssr:

What you should note with this approach: It's basically the opposite of what you want when you're using SSR. In my use case I'm using SSR for guaranteed SEO, but when I use react-no-ssr then of course it'll skip this part from server side rendering resulting in missing content. I need – for example – a workaround to use ScrollMagic (which depends on window) to implement nice looking contents. But, when using this approach, the entire page would be empty on the server side since my entire content is going through that component using ScrollMagic.

The require solution works well, but it looks hacky, there's should be better solution.

I totally agree.

@iamstarkov
Copy link

i wrote special component to use Spectacle

// prettier-ignore
const spectacleExports = [ "Anim", "Appear", "BlockQuote", "Cite", "CodePane", "Code", "ComponentPlayground", "Deck", "Fill", "Fit", "Heading", "Image", "GoToAction", "Layout", "Link", "ListItem", "List", "Magic", "Markdown", "MarkdownSlides", "Notes", "Quote", "S", "Slide", "SlideSet", "TableBody", "TableHeader", "TableHeaderItem", "TableItem", "TableRow", "Table", "Text", "Typeface", "themes" ];

const setToFalse = (state, key) => ({ ...state, [key]: false });
const setTo = spectacle => (state, key) => ({
  ...state,
  [key]: spectacle[key]
});
const isReady = o => Object.values(o).every(x => !!x);

class WithSpectacle extends React.Component {
  constructor(props) {
    super(props);

    this.state = spectacleExports.reduce(setToFalse, {});
  }
  componentDidMount() {
    const spectacle = require("spectacle");

    this.setState(spectacleExports.reduce(setTo(spectacle), {}));
  }
  render() {
    if (!isReady(this.state)) return null;

    return this.props.render(this.state);
  }
}

@felizuno
Copy link

felizuno commented Jul 16, 2018

FWIW We had a similar issue with a PDF rendering library and used this helper to work around this issue.

Our problem was that rendering anything which depends on PDF.js server-side will throw errors, as the library itself contains browser API calls (related to web workers) which are not safe in Node. By using the above helper to import the component that handles PDF rendering we were able to defer parsing of the library code to the browser, where it ran without error:

import dynamic from 'next/dynamic';
// This component depends on the client-only PDF library
const PDFViewer = dynamic(import('../../components/PDFViewer'), { ssr: false });

class ExampleClass extends Component {
  render() {
    return (
      <div>
        <PDFViewer />
      </div>
    );
  }
}

@julkue
Copy link

julkue commented Aug 2, 2018

@felizuno But how does that work with node modules (located in node_modules and not React components) that you should be able to work with in componentDidMount?

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

No branches or pull requests