Search code examples
reactjsnpmwebpackwebpack-5code-splitting

How to split a webpack + react component library per component for next.js


The Problem

Over the last year and a half I have been developing a components library using Storybook, React and Webpack 5 for my team. Recently we have started to look at Next.JS and have a major project well underway using its framework. This however has created some challenges, as next.js renders Server-side and Client-side, meaning any imports that use client-side exclusive objects/functions etc. cause an error. Now this can be solved using dynamic imports, but that then creates loading times or missing content if not handled correctly.

Self Is Not Defined Error

Our whole components library causes this SSR error. It doesn't matter if you are importing a button or a popover that actually utilises window, you have to use dynamic imports. This then creates loading times and missing content on the rendered page. We can't even use the loading component in the library, as that needs loading. We also have the problem, that even if we took out all references to window or document in our code, some of our dependencies reference them somewhere and we just can't avoid it.

What we would like to be able to do with the library is import it in several ways to isolate window and document calls to their individual components, so we can avoid dynamic loading wherever possible.

  • import { Component } from 'Library'
  • import { Component } from 'Library/ComponentCategory'
  • import { Component } from 'Library/Component'

The reason behind the three imports is simple:

  • We want to be able to import the whole library and any components we need from it. Other than in Next.JS this is not an issue. In Next.JS we would never import this way.
  • We want to be able to import a components category, so if we are using multiple components from that category we can import them with one import, not several. i.e Form components. This should only import the code and modules it requires. If a category doesn't reference client exclusive code then it should be able to be imported normally.
  • We want to be able to import an individual component, that only brings along the code and modules it needs, so if we need to dynamically import, we do it on an individual basis, not library wide.

This way of importing has been implemented, but no matter which route you take, it still fires the Next.JS 'self is not defined' error. This seems to mean, that even on an individual component import, the entire code base of the library is still referenced.

Attempted Solutions

Window Document Checks and Removal of Unneeded References

We removed any unneeded references to client exclusive code and added conditional statements around any statements we could not remove.

if (typeof window !== 'undefined') {
   // Do the thing with window i.e window.location.href = '/href'
}

This didn't have any effect, largely due to the nature of the npm ecosystem. Somewhere in the code, document, screen or window is called and there isn't a lot I can do about it. We could wrap every import in this conditional, but lets be honest, that is pretty gross and likely wouldn't solve the problem without other steps being taken.

Library splitting

Using webpack 5 entry, output and splitChunks magic has also not solved the problem.

The first step was to configure entry and output. So I set my entry to something like this:

entry: {
    // Entry Points //
    //// Main Entry Point ////
    main: './src/index.ts',
    //// Category Entry Points ////
    Buttons: './src/components/Buttons', // < - This leads to an index.ts
    ...,
    //// Individual Component Entry Points ////
    Button: './src/components/Buttons/Button.tsx',
    OtherComponent: '...',
    ...,
},

And my output to:

output: {
    path: path.resolve(__dirname, './dist'),
    filename: '[name].js',
    library: pkg.name,
    libraryTarget: 'umd',
    umdNamedDefine: true,
  },

This has allowed us to now import the library as a whole, via categories or as individual components. We can see this, as in the dist folder of the library, there are now Component.js(.map) files. Unfortunately this has still not allowed us to creep past the SSR error. We can import Button from Library/dist/Button but Next.JS still screams about code its not even using.

The next step on this adventure, and currently the last, was to use Webpacks splitChunks functionality, alongside the entry/output changes.

  optimization: {
    runtimeChunk: 'single',
    splitChunks: {
      chunks: 'all',
      maxInitialRequests: Infinity,
      minSize: 0,
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name(module) {
            // get the name. E.g. node_modules/packageName/not/this/part.js
            // or node_modules/packageName
            const packageName = module.context.match(/[\\/]node_modules[\\/](.*?)([\\/]|$)/)[1];

            // npm package names are URL-safe, but some servers don't like @ symbols
            return `npm.${packageName.replace('@', '')}`;
          },
        },
      },
    },
  },

This has also not worked, though I am not 100% sure its even firing correctly, as I see no npm.packageName in my dist folder. There are now a bunch of 4531.js (3-4 numbers followed by js), but opening these, contained within the webpack generated code, are some classNames I have written, or the string that has been generated for my scss-modules.

What I am Going To Try Next

ALL RESULTS WILL BE POSTED ON THREAD

Making A Dummy Test Library

Making a library of three simple components (Red, Blue, Green) and trying to split them out. One will contain window, and using npm pack, we will keep making changes till something sticks in Next.JS. I don't necessarily think this will help, but may improve understanding of how everything is working.

Possible Solutions

Lerna + MicroLibraries

Funnily enough I looked at this when I first started on the library, realised it was a dragon I didn't need to tackle and ran away. The solution here, would be to separate out the categories into their own self contained npm packages. These would then be contained in a lerna enviroment. This could also be done without a tool like lerna, but we do not want to install part of the components library but all of it. I still feel like this route is over complicated, unnecessary and will cause more things to maintain in the long run. It is also going to require a rethink of structure and a rewrite of some sections i.e storybook, the docker image that deploys the storybook

Use Rollup or insert bundler name here

Again, this solution has a funny anecdote to go along with it. Lots of JS developers do not understand some of the fundamental tools they use. That isn't to say they are bad developers, but CLI tools like create-react-app generate a lot of the required project boilerplate, meaning the developer can focus on the functionality of their application. This was the case for my colleague and I, so we decided that it made sense to start from scratch. Webpack was the bundler I chose (and thank god for all those webpack 5 upgrades) but maybe this was the wrong decision and I should have used rollup?

Don't use Next.js

It is possible that this is an issue of Next.JS and that in reality Next.JS is the problem. I think that is a bad way to look at things however. Next.JS is a very cool framework and other than the problem being described here, has been wonderful to use. Our existing deployed application stacks are; Webpack, pug and express. Maybe deciding to use a framework is a bad move and we need to rewrite the application currently being developed in next. I do recall seeing that SSR errors could arise from react component life cycle methods/useEffect, so perhaps that has been the real culprit this whole time.

Extra

The library uses pnpm as its package manager.

Library Dependencies

"dependencies": {
    "@fortawesome/fontawesome-pro": "^5.15.4",
    "@fortawesome/fontawesome-svg-core": "^1.2.36",
    "@fortawesome/free-regular-svg-icons": "^5.15.4",
    "@fortawesome/free-solid-svg-icons": "^5.15.4",
    "@fortawesome/pro-regular-svg-icons": "^5.15.4",
    "@fortawesome/react-fontawesome": "^0.1.16",
    "classname": "^0.0.0",
    "classnames": "^2.3.1",
    "crypto-js": "^4.1.1",
    "date-fns": "^2.28.0",
    "formik": "^2.2.9",
    "html-react-parser": "^1.4.5",
    "js-cookie": "^3.0.1",
    "lodash": "^4.17.21",
    "nanoid": "^3.2.0",
    "react-currency-input-field": "^3.6.4",
    "react-datepicker": "^4.6.0",
    "react-day-picker": "^7.4.10",
    "react-modal": "^3.14.4",
    "react-onclickoutside": "^6.12.1",
    "react-router-dom": "^6.2.1",
    "react-select-search": "^3.0.9",
    "react-slider": "^1.3.1",
    "react-tiny-popover": "^7.0.1",
    "react-toastify": "^8.1.0",
    "react-trix": "^0.9.0",
    "trix": "1.3.1",
    "yup": "^0.32.11"
  },
  "devDependencies": {
    "postcss-preset-env": "^7.4.2",
    "@babel/core": "^7.16.12",
    "@babel/preset-env": "^7.16.11",
    "@babel/preset-react": "^7.16.7",
    "@babel/preset-typescript": "^7.16.7",
    "@dr.pogodin/babel-plugin-css-modules-transform": "^1.10.0",
    "@storybook/addon-actions": "^6.4.14",
    "@storybook/addon-docs": "^6.4.14",
    "@storybook/addon-essentials": "^6.4.14",
    "@storybook/addon-jest": "^6.4.14",
    "@storybook/addon-links": "^6.4.14",
    "@storybook/addons": "^6.4.14",
    "@storybook/builder-webpack5": "^6.4.14",
    "@storybook/manager-webpack5": "^6.4.14",
    "@storybook/react": "^6.4.14",
    "@storybook/theming": "^6.4.14",
    "@svgr/webpack": "^6.2.0",
    "@testing-library/react": "^12.1.2",
    "@types/enzyme": "^3.10.11",
    "@types/enzyme-adapter-react-16": "^1.0.6",
    "@types/jest": "^27.4.0",
    "@types/react": "^17.0.38",
    "@types/react-datepicker": "^4.3.4",
    "@types/react-dom": "^17.0.11",
    "@types/react-slider": "^1.3.1",
    "@types/yup": "^0.29.13",
    "@typescript-eslint/eslint-plugin": "^5.10.1",
    "@typescript-eslint/parser": "^5.10.1",
    "@vgrid/sass-inline-svg": "^1.0.1",
    "@wojtekmaj/enzyme-adapter-react-17": "^0.6.6",
    "audit-ci": "^5.1.2",
    "babel-loader": "^8.2.3",
    "babel-plugin-inline-react-svg": "^2.0.1",
    "babel-plugin-react-docgen": "^4.2.1",
    "babel-plugin-react-remove-properties": "^0.3.0",
    "clean-css-cli": "^5.5.0",
    "clean-webpack-plugin": "^4.0.0",
    "copy-webpack-plugin": "^10.2.1",
    "css-loader": "^6.5.1",
    "css-modules-typescript-loader": "^4.0.1",
    "dependency-cruiser": "^11.3.0",
    "enzyme": "^3.11.0",
    "eslint": "^8.7.0",
    "eslint-config-airbnb": "^19.0.4",
    "eslint-config-airbnb-typescript": "^16.1.0",
    "eslint-config-prettier": "^8.3.0",
    "eslint-import-resolver-node": "^0.3.6",
    "eslint-import-resolver-typescript": "^2.5.0",
    "eslint-plugin-css-modules": "^2.11.0",
    "eslint-plugin-import": "^2.25.4",
    "eslint-plugin-jest": "^26.0.0",
    "eslint-plugin-jsx-a11y": "^6.5.1",
    "eslint-plugin-prettier": "^4.0.0",
    "eslint-plugin-react": "^7.28.0",
    "eslint-plugin-react-hooks": "^4.3.0",
    "eslint-plugin-sonarjs": "^0.11.0",
    "eslint-webpack-plugin": "^3.1.1",
    "html-webpack-plugin": "^5.5.0",
    "husky": "^7.0.0",
    "identity-obj-proxy": "^3.0.0",
    "jest": "^27.4.7",
    "jest-environment-enzyme": "^7.1.2",
    "jest-environment-jsdom": "^27.4.6",
    "jest-enzyme": "^7.1.2",
    "jest-fetch-mock": "^3.0.3",
    "jest-sonar-reporter": "^2.0.0",
    "jest-svg-transformer": "^1.0.0",
    "lint-staged": "^12.3.1",
    "mini-css-extract-plugin": "^2.5.3",
    "narn": "^2.1.0",
    "node-notifier": "^10.0.0",
    "np": "^7.6.0",
    "postcss": "^8.4.5",
    "postcss-loader": "^6.2.1",
    "precss": "^4.0.0",
    "prettier": "^2.5.1",
    "prettier-eslint": "^13.0.0",
    "react": "^17.0.2",
    "react-dom": "^17.0.2",
    "react-is": "^17.0.2",
    "sass": "^1.49.0",
    "sass-loader": "^12.4.0",
    "sass-true": "^6.0.1",
    "sonarqube-scanner": "^2.8.1",
    "storybook-formik": "^2.2.0",
    "style-loader": "^3.3.1",
    "ts-jest": "^27.1.3",
    "ts-loader": "^9.2.6",
    "ts-prune": "^0.10.3",
    "typescript": "^4.5.5",
    "typescript-plugin-css-modules": "^3.4.0",
    "url-loader": "^4.1.1",
    "webpack": "^5.67.0",
    "webpack-cli": "^4.9.2",
    "webpack-dev-server": "^4.7.3",
    "webpack-node-externals": "^3.0.0"
  },
  "peerDependencies": {
    "react": ">=16.14.0",
    "react-dom": ">=16.14.0"
  },

Thanks for reading and any suggestions would be great.

Update 1

First of all here is the webpack config I forgot to include, minus all the entry points.

const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const nodeExternals = require('webpack-node-externals');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const inliner = require('@vgrid/sass-inline-svg');
const ESLintPlugin = require('eslint-webpack-plugin');
const pkg = require('./package.json');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  // Note: Please add comments to new entry point category additions
  entry: {
    // Entry Points //
    //// Main Entry Point ////
    main: './src/index.ts',
    //// Category Entry Points ////
    Buttons: './src/components/Buttons/index.ts',
    ...
  },
  // context: path.resolve(__dirname),
  resolve: {
    modules: [__dirname, 'node_modules'],
    extensions: ['.ts', '.tsx', '.js', '.jsx', '.json', '.scss', '.css'],
  },
  output: {
    path: path.resolve(__dirname, './dist'),
    filename: '[name].js',
    library: pkg.name,
    libraryTarget: 'umd',
    umdNamedDefine: true,
  },
  optimization: {
    runtimeChunk: 'single',
    splitChunks: {
      chunks: 'all',
      maxInitialRequests: Infinity,
      minSize: 0,
      minChunks: 1,
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name(module) {
            // get the name. E.g. node_modules/packageName/not/this/part.js
            // or node_modules/packageName
            const packageName = module.context.match(/[\\/]node_modules[\\/](.*?)([\\/]|$)/)[1];

            // npm package names are URL-safe, but some servers don't like @ symbols
            return `npm.${packageName.replace('@', '')}`;
          },
        },
      },
    },
  },
  devtool: 'source-map',
  module: {
    rules: [
      // ! This rule generates the ability to use S/CSS Modules but kills global css
      {
        test: /\.(scss|css)$/,
        use: [
          MiniCssExtractPlugin.loader,
          { loader: 'css-modules-typescript-loader' },
          {
            loader: 'css-loader', //2
            options: {
              modules: {
                localIdentName: '[local]_[hash:base64:5]',
              },
              importLoaders: 1,
            },
          },
          {
            loader: 'postcss-loader',
            options: {
              postcssOptions: {
                extract: true,
                modules: true,
                use: ['sass'],
              },
            },
          },
          'sass-loader',
        ],
        include: /\.module\.css$/,
      },
      // ! This allows for global css alongside the module rule.  Also generates the d.ts files for s/css modules (Haven't figured out why).
      {
        test: /\.(scss|css)$/,
        use: [
          MiniCssExtractPlugin.loader,
          { loader: 'css-modules-typescript-loader' },
          'css-loader',
          {
            loader: 'postcss-loader',
            options: {
              postcssOptions: {
                extract: true,
                use: ['sass'],
              },
            },
          },
          'sass-loader',
        ],
        exclude: /\.module\.css$/,
      },
      {
        test: /\.(ts|tsx)$/,
        loader: 'ts-loader',
        exclude: /node_modules/,
      },
      {
        test: /\.(js|jsx)$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
        },
      },
      // {
      //   test: /\.(js|jsx|ts|tsx)$/,
      //   exclude: /node_modules/,
      //   use: {
      //     loader: 'eslint-webpack-plugin',
      //   },
      // },
      {
        test: /\.(png|jpg|jpeg|woff|woff2|eot|ttf)$/,
        type: 'asset/resource',
      },
      {
        test: /\.svg$/,
        use: ['@svgr/webpack', 'url-loader'],
      },
    ],
  },

  plugins: [
    new CleanWebpackPlugin(),
    new CopyWebpackPlugin({
      patterns: [{ from: './src/scss/**/*.scss', to: './scss/' }],
    }),
    new MiniCssExtractPlugin(),
    new ESLintPlugin(),
  ],
  externals: [nodeExternals()],
};

Extract the CSS!!!

An answer suggested it was the CSS modules being injected into the HTML that was the issue and I needed to extract. I updated the PostCSS rules in my webpack, to have extract: true and modules: true before recognising the problem. I am extracting all the css with webpack using the MiniCSSExtractPlugin. Due to Content-Security-Policy style rules on the webapps my company develops, the injection of styles into the HTML via tools like Style-Loader breaks everything. There are also very good argument against using tools like style-loader beyond a development environment.

I did more research into webpack extraction and saw people recommending different tools that played better with SSR. I have seen recommendations for MiniTextExtractPlugin (which was deprecated in favour of MiniCSSExtractPlugin), NullLoader (which I believe solves a completely different problem to the one I am facing), CSSLoader/Locales (which I can't find documentation for in the css-loader docs) and a few others; ObjectLoader, as well as style-loader, iso-style-loader etc. During my research into this, I recognised I was in a dead end. Perhaps MiniCSSExtractPlugin works poorly in the webpack of an application utilising SSR, but to quote an old video, "this is a library". Its built, packaged and published long before we install and utilise it in our application.

Next JS next.config.js next-transpile-modules

I updated the Next.JS config of my application based on this and a few other posts. https://github.com/vercel/next.js/issues/10975#issuecomment-605528116

This is now my next.js config

const withTM = require('next-transpile-modules')(['@company/package']); // pass the modules you would like to see transpiled

module.exports = withTM({
  webpack: (config, { isServer }) => {
    // Fixes npm packages that depend on `fs` module
    if (!isServer) {
      config.resolve.fallback = {
        fs: false,
      };
    }
    return config;
  },
});

This also did not solve the problem.

Stop SCSS being bundled with the Library

The library uses CopyWebpackPlugin to copy all the scss into a directory within the build. This allows us to expose mixins, variables, common global classnames etc. In an attempt to debug the webpack, I turned this off. This had no effect but I will document it anyway.

Update 1 Conclusion

I am currently replacing the bundler with rollup just to test whether or not it has any effect.

Update 2 Electric Boogaloo

So rollup was a failure, didn't solve any problems but did bring some issues to light.

Due to the nature of the problem, I decided to just dynamically load anything from the library that was needed, and to extract the loader from the library so I could use it for the dynamic loading.

If I manage to solve this problem in the way I intended, I will make another update. However I believe that this is just another issue with Next to add to the list.


Solution

  • TLDR; Use next 13 and the "use client" string to solve self is not defined errors when using window or React hooks.


    So I thought I would post an answer now we fully understand the issue.

    No matter what we did, in next 12 this was always going to happen. The moment we used anything like event listners or react (non-server components), next would return the self is not defined error, because they rely on browser functionality. I had suspected react could be causing the issues, but my focus had been on window as that was what was always mentioned in the error. No code splitting would have ever solved this and I'd actually split the codebase as far as I could. Of course in hindsight I should have always realised the issue was from my usage of hooks within a server environment, but I had kind of expected for THE react stack framework to handle this a bit better, or at least be verbose in what the problem was.

    Now there is a solution. Ignore all previous versions of next, and upgrade to 13. In next 13 this is solved completely with the "use client" comment you can put at the top of your js/ts file. Next reads this and doesn't try and render the component server side. I hope this will help anyone else who has this issue further down the line.