gajus / babel-plugin-react-css-modules

Transforms styleName to className using compile time CSS module resolution.
Other
2.05k stars 162 forks source link

Adding class to `styleName` and then adding class to CSS file does not work with HMR #200

Open phegman opened 6 years ago

phegman commented 6 years ago

I have this all setup and working and HMR will work except for in one use case. If you first add a class to styleName and then add the class to your css file HMR will not work. You need to go back to the JS file and re-save that file. This is the case no matter what handleMissingStyleName is set to. I have tried changing a number of settings in my Webpack config, but no luck. Any ideas are greatly appreciated. This is my Webpack config if that helps:

const path = require('path')
const webpack = require('webpack')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const CopyWebpackPlugin = require('copy-webpack-plugin')
const UglifyJsPlugin = require('uglifyjs-webpack-plugin')
const CleanWebpackPlugin = require('clean-webpack-plugin')
const WriteFilePlugin = require('write-file-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin')

module.exports = (env, argv) => {
  return {
    context: path.resolve(__dirname, 'src'),
    entry: './index.js',
    output: {
      path: path.resolve(__dirname, 'build'),
      filename: 'module.min.js'
    },
    optimization: {
      minimizer: [
        new UglifyJsPlugin({
          cache: true,
          parallel: true,
          sourceMap: false
        }),
        new OptimizeCSSAssetsPlugin({})
      ]
    },
    resolve: {
      alias: {
        react: 'preact-compat',
        'react-dom': 'preact-compat',
        'create-react-class': 'preact-compat/lib/create-react-class',
        'react-dom-factories': 'preact-compat/lib/react-dom-factories'
      }
    },
    module: {
      rules: [
        {
          test: /\.scss$/,
          use: [
            argv.mode !== 'production'
              ? 'style-loader'
              : MiniCssExtractPlugin.loader,
            {
              loader: 'css-loader',
              options: {
                sourceMap: argv.mode === 'production' ? false : true,
                importLoader: 2,
                modules: true,
                localIdentName:
                  argv.mode !== 'production'
                    ? '[local]-[hash:base64]'
                    : '[hash:base64]'
              }
            },
            {
              loader: 'resolve-url-loader'
            },
            {
              loader: 'postcss-loader',
              options: {
                sourceMap: true,
                plugins: [
                  require('autoprefixer')({
                    browsers: ['last 4 versions']
                  })
                ]
              }
            },
            {
              loader: 'sass-loader',
              options: {
                sourceMap: true,
                data: '@import "variables"; @import "mixins";',
                includePaths: [path.resolve(__dirname, './src/style')]
              }
            }
          ]
        },
        {
          test: /\.jsx?$/,
          exclude: /node_modules/,
          use: {
            loader: 'babel-loader',
            options: {
              presets: ['@babel/preset-env'],
              plugins: [
                ['@babel/plugin-proposal-decorators', { legacy: true }],
                '@babel/plugin-proposal-class-properties',
                ['transform-react-jsx', { pragma: 'h' }],
                [
                  'react-css-modules',
                  {
                    context: path.resolve(__dirname, 'src'),
                    webpackHotModuleReloading: true,
                    handleMissingStyleName: 'ignore',
                    generateScopedName:
                      argv.mode !== 'production'
                        ? '[local]-[hash:base64]'
                        : '[hash:base64]',
                    filetypes: {
                      '.scss': {
                        syntax: 'postcss-scss'
                      }
                    }
                  }
                ]
              ]
            }
          }
        },
        {
          test: /\.json$/,
          use: 'json-loader'
        },
        {
          test: /\.(xml|html|txt|md)$/,
          use: 'raw-loader'
        },
        {
          test: /\.(woff2?|ttf|eot|jpe?g|png|gif)(\?.*)?$/i,
          use:
            argv.mode === 'production'
              ? 'file-loader?name=[name].[ext]'
              : 'url-loader'
        },
        {
          test: /\.svg$/,
          oneOf: [
            {
              include: [/css|non-inline-svg/],
              use:
                argv.mode === 'production'
                  ? 'file-loader?name=[name].[ext]'
                  : 'url-loader'
            },
            {
              exclude: [/css|non-inline-svg/],
              loader: 'svg-inline-loader'
            }
          ]
        }
      ]
    },
    plugins: [
      new CleanWebpackPlugin(['build/**']),
      new HtmlWebpackPlugin({
        template: './index.html',
        minify: { collapseWhitespace: true }
      }),
      new HtmlWebpackPlugin({
        template: './module.html',
        filename: 'module.html',
        inject: false,
        minify: { collapseWhitespace: true }
      }),
      new WriteFilePlugin(),
      new MiniCssExtractPlugin({
        filename: 'module.min.css'
      }),
      new CopyWebpackPlugin([
        { from: './manifest.json', to: './' },
        { from: './favicon.ico', to: './' }
      ])
    ],
    devServer: {
      port: process.env.PORT || 8080,
      host: 'localhost',
      publicPath: '/',
      contentBase: './src',
      historyApiFallback: true,
      open: true,
      openPage: ''
    }
  }
}
hinok commented 6 years ago

@phegman Seems related to https://github.com/gajus/babel-plugin-react-css-modules/issues/154

phegman commented 6 years ago

@hinok I really appreciate the response! Unfortunately that doesn't appear to be the issue as cacheDirectory is false by default. I tried explicitly setting it to cacheDirectory to false and still no luck. The issue isn't a total deal breaker, it just ends up being a bit annoying to have to re-save the JS file after adding the class to the CSS file. Overall though it is still SO much better than not using this babel plugin so thanks for an amazing package! I will keep playing around with it in my free time and see if I can come up with a solution.

antonholmquist commented 5 years ago

I'm seeing the same issue:

  1. Set a style name that doesn't yet exist, and save
  2. Add the corresponding class to the css file, and save
  3. This will not trigger the class name resolution to be made again
  4. I need to to back to the js file and re-save for the resolution to run again.

It would be great with an option to set the class name to the element no matter if there exist a corresponding class name in the css file or not.

otakustay commented 5 years ago

Since this is a babel plugin, it could not back-notify webpack to recompile javascript, to address this issue, I've written a loader to establish the dependency between js and less, a very simple one:

import {promisify} from 'util';
import * as babel from '@babel/core';
import traverse from '@babel/traverse';
import * as webpack from 'webpack';
import {getParseOnlyBabelConfig} from '../../config';
import {BabelConfigOptions} from '../../../types';

type StyleNameDependencies = [boolean, string[]];
type Resolve = (request: string) => Promise<string>;
type CollectCallback = (result: StyleNameDependencies) => void;

const babelConfig = getParseOnlyBabelConfig({browserSupport: '', usage: 'build'});

const parse = (source: string, babelConfig: babel.TransformOptions): Promise<babel.ParseResult | null> => {
    const execute = (resolve: (ast: babel.ParseResult | null) => void) => babel.parse(
        source,
        babelConfig,
        (error, ast) => resolve(error ? null : ast)
    );
    return new Promise(execute);
};

const collectDependencies = async (source: string, filename: string, resolve: Resolve, callback: CollectCallback) => {
    const ast = await parse(source, {...babelConfig, filename});
    const result: StyleNameDependencies = [false, []];

    if (!ast) {
        callback(result);
        return;
    }

    traverse(
        ast,
        {
            // tslint:disable-next-line function-name
            JSXIdentifier({node, parent}) {
                if (parent.type === 'JSXAttribute' && node.name === 'styleName') {
                    result[0] = true;
                }
            },
            // tslint:disable-next-line function-name
            ImportDeclaration({node}) {
                const value = node.source.value;
                // Only collect sass imports
                if (value.endsWith('.scss')) {
                    result[1].push(value);
                }
            },
        }
    );
    const dependencies = await Promise.all(result[1].map(resolve));
    result[1] = dependencies;
    callback(result);
};

const loader: webpack.loader.Loader = function styleNameLoader(content, sourceMap) {
    if (this.cacheable) {
        this.cacheable();
    }

    this.async();

    const resolve = promisify(this.resolve);
    collectDependencies(
        content.toString(),
        this.resourcePath,
        request => resolve(this.context, request),
        ([hasStyleName, dependencies]) => {
            if (hasStyleName) {
                dependencies.forEach(this.addDependency);
            }
            this.callback(null, content, sourceMap);
        }
    );
};

export default loader;

Put this loader before babel-loader then you have everything you want.

This can slow down your webpack build, so just include this loader in dev-server mode, exclude it in build

praveenpuglia commented 1 year ago

@otakustay Where is the getParseOnlyBabelConfig function coming from ? what does it do?

otakustay commented 1 year ago

@otakustay Where is the getParseOnlyBabelConfig function coming from ? what does it do?

It's simply for parsing js syntax, for us it's preset-typescript and preset-react, or just load it from babel.config.js

praveenpuglia commented 1 year ago

Can you please elaborate further? You are saying I can just import the babel.config.json and assign that to babelConfig?

otakustay commented 1 year ago

Can you please elaborate further? You are saying I can just import the babel.config.json and assign that to babelConfig?

Yes, import babel.config.json and assign to babelConfig variable, however this is an example I made years ago, webpack is in V4 at that time, so it may still work and may not.

praveenpuglia commented 1 year ago

Holy crap this worked! <3. Update: For folks who are using commonjs and JS instead of TS. Works with webpack v5.

The Webpack Plugin

/**
 * Courtesy to original solution.
 * https://github.com/gajus/babel-plugin-react-css-modules/issues/200#issuecomment-472248349
 *
 * This plugin sets up a dependency between JS and CSS files & helps babel
 * notify webpack to trigger the HMR
 */
const { promisify } = require('util');
const babel = require('@babel/core');
const traverse = require('@babel/traverse').default;
const babelConfig = require('./babel.config');

const parse = (source, config) => {
  const execute = (resolve) =>
    babel.parse(source, config, (error, ast) => resolve(error ? null : ast));
  return new Promise(execute);
};

const collectDependencies = async (source, filename, resolve, callback) => {
  const ast = await parse(source, { ...babelConfig, filename });
  const result = [false, []];

  if (!ast) {
    callback(result);
    return;
  }

  traverse(ast, {
    JSXIdentifier({ node, parent }) {
      if (parent.type === 'JSXAttribute' && node.name === 'styleName') {
        result[0] = true;
      }
    },

    ImportDeclaration({ node }) {
      const { value } = node.source;
      // Only collect css imports
      if (value.endsWith('.css')) {
        result[1].push(value);
      }
    },
  });
  const dependencies = await Promise.all(result[1].map(resolve));
  result[1] = dependencies;
  callback(result);
};

const loader = function styleNameLoader(content, sourceMap) {
  if (this.cacheable) {
    this.cacheable();
  }

  this.async();

  const resolve = promisify(this.resolve);
  collectDependencies(
    content.toString(),
    this.resourcePath,
    (request) => resolve(this.context, request),
    ([hasStyleName, dependencies]) => {
      if (hasStyleName) {
        dependencies.forEach(this.addDependency);
      }
      this.callback(null, content, sourceMap);
    },
  );
};

module.exports = loader;

Webpack Config

// Inside the rule you use with babel-loader.
{
  test: /\.(ts|js)x?$/,
  include: [
    path.resolve(__dirname, 'src/'),
  ],
  use: [
    {
      loader: 'babel-loader',
    },
    // We only want this on dev. 
    ...(env.ENV === 'production'
      ? [
          {
            loader: require.resolve('./CSSModulesPlugin.js'),
          },
        ]
      : []),
  ],
},