Skip to content

ux-react: add CSS from within components #1370

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

Closed
nerdess opened this issue Apr 10, 2025 · 6 comments
Closed

ux-react: add CSS from within components #1370

nerdess opened this issue Apr 10, 2025 · 6 comments

Comments

@nerdess
Copy link

nerdess commented Apr 10, 2025

I am playing with ux-react on a Symfony project that uses webpack-encore.

It works fine, but there is one problem: importing CSS within a React component does not work?!

React component

import Toolbar from './toolbar/Toolbar';
import Grid from './grid/Grid';
import './calendar.scss'; //why is this not working?

const Calendar = () => {

    return (
        <div className="ts-calendar">
            <Toolbar />
            <Grid />
        </div>
    )
}

export default Calendar;

webpack.config.js

const Encore = require('@symfony/webpack-encore');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const path = require('path');

Encore
    .setOutputPath('public/build/')
    .setPublicPath('/build')

    .addExternals({
        bootstrap: 'bootstrap'
    })

    .addEntry('appJS', './assets/js/app')
    .addEntry('appCSS', './assets/css/app.scss')

    //aliases for JS/React, please also add to tsconfig.json
    .addAliases({
        '@components': path.resolve(__dirname, 'assets/react/components'),
        '@atomic': path.resolve(__dirname, 'assets/react/components/atomic'),
        '@react': path.resolve(__dirname, 'assets/react'),
        '@images': path.resolve(__dirname, 'assets/images')
    })

    //load SVGs as React components (Part 1/2)
    .addRule({
        test: /\.svg$/i,
        issuer: /\.[jt]sx?$/,  // Ensures only JS/TS files can import SVGs
        use: ['@svgr/webpack']
    })

    //load SVGs as React components (Part 2/2)
    //this is needed to supress default Encore loaders, see https://stackoverflow.com/questions/75683935/svgs-not-being-imported-correctly-with-encore-svgr-and-react
    .configureLoaderRule("images", (loaderRule) => {
        loaderRule.test = new RegExp(loaderRule.test.toString().replace('|svg', ''));
    })

    .disableSingleRuntimeChunk()
    .cleanupOutputBeforeBuild()
    .enableSourceMaps()
    .enableVersioning(Encore.isProduction())

    .addPlugin(new CopyWebpackPlugin({
        patterns: [
            { from: './assets/images', to: 'images' },
            { from: './assets/jslibs', to: 'jslibs' },
            { from: './assets/data', to: 'data' },
            { from: './assets/email', to: 'email' },
            { from: './assets/easyadmin', to: 'easyadmin' },
            { from: './assets/pdf', to: 'pdf' },
            { from: './assets/fonts', to: 'unbounce/[name][ext]' },
            { from: './assets/fonts', to: 'fonts' },
            { from: './node_modules/bootstrap/dist/js/bootstrap.bundle.min.js', to: 'bootstrap.bundle.min.js' }
        ]
    }))

    .enableTypeScriptLoader()

    .configureBabel(() => {}, {
        includeNodeModules: ['@pageclip/valid-form']
    })

    .enableSassLoader()
    .configureCssLoader(options => {
        options.modules = {
          auto: (resourcePath) => {
            //only enable inside 'assets/react' path
            return resourcePath.includes(path.join('assets', 'react'))
          }
        };
      })

    .enableReactPreset()
    .enableStimulusBridge('./assets/controllers.json')
;

module.exports = Encore.getWebpackConfig();

Basically, I need a simple import './calendar.scss'; to work within the folder where React sits (assets/react).

Any hints would be appreciated!

@nerdess
Copy link
Author

nerdess commented Apr 10, 2025

This is my full config as per Encore.getWebpackConfig()

{
  context: '/var/www/symfony',
  entry: {
    appJS: './assets/js/app',
    appCSS: './assets/css/app.scss'
  },
  mode: 'development',
  output: {
    clean: {},
    path: '/var/www/symfony/public/build',
    filename: '[name].js',
    assetModuleFilename: 'assets/[name].[hash:8][ext]',
    publicPath: '/build/',
    pathinfo: true
  },
  module: {
    rules: [
      {
        test: /\.(m?jsx?)$/,
        exclude: [Function (anonymous)],
        use: [
          {
            loader: '/var/www/symfony/node_modules/babel-loader/lib/index.js',
            options: {
              cacheDirectory: true,
              sourceType: 'unambiguous',
              presets: [
                [
                  '/var/www/symfony/node_modules/@babel/preset-env/lib/index.js',
                  {
                    modules: false,
                    targets: {},
                    useBuiltIns: false,
                    corejs: null
                  }
                ],
                [
                  '/var/www/symfony/node_modules/@babel/preset-react/lib/index.js',
                  { runtime: 'automatic' }
                ]
              ],
              plugins: []
            }
          }
        ]
      },
      {
        resolve: { mainFields: [ 'style', 'main' ], extensions: [ '.css' ] },
        test: /\.(css)$/,
        oneOf: [
          {
            resourceQuery: /module/,
            use: [
              {
                loader: '/var/www/symfony/node_modules/mini-css-extract-plugin/dist/loader.js',
                options: {}
              },
              {
                loader: '/var/www/symfony/node_modules/css-loader/dist/cjs.js',
                options: {
                  sourceMap: true,
                  importLoaders: 0,
                  modules: { auto: [Function: auto] }
                }
              }
            ]
          },
          {
            use: [
              {
                loader: '/var/www/symfony/node_modules/mini-css-extract-plugin/dist/loader.js',
                options: {}
              },
              {
                loader: '/var/www/symfony/node_modules/css-loader/dist/cjs.js',
                options: {
                  sourceMap: true,
                  importLoaders: 0,
                  modules: { auto: [Function: auto] }
                }
              }
            ]
          }
        ]
      },
      {
        test: /\/\.(png|jpg|jpeg|gif|ico|webp|avif)$\//,
        oneOf: [
          {
            resourceQuery: /copy-files-loader/,
            type: 'javascript/auto'
          },
          {
            type: 'asset/resource',
            generator: { filename: 'images/[name].[hash:8][ext]' },
            parser: {}
          }
        ]
      },
      {
        test: /\.(woff|woff2|ttf|eot|otf)$/,
        oneOf: [
          {
            resourceQuery: /copy-files-loader/,
            type: 'javascript/auto'
          },
          {
            type: 'asset/resource',
            generator: { filename: 'fonts/[name].[hash:8][ext]' },
            parser: {}
          }
        ]
      },
      {
        resolve: {
          mainFields: [ 'sass', 'style', 'main' ],
          extensions: [ '.scss', '.sass', '.css' ]
        },
        test: /\.s[ac]ss$/,
        oneOf: [
          {
            resourceQuery: /module/,
            use: [
              {
                loader: '/var/www/symfony/node_modules/mini-css-extract-plugin/dist/loader.js',
                options: {}
              },
              {
                loader: '/var/www/symfony/node_modules/css-loader/dist/cjs.js',
                options: {
                  sourceMap: true,
                  importLoaders: 0,
                  modules: { auto: [Function: auto] }
                }
              },
              {
                loader: '/var/www/symfony/node_modules/resolve-url-loader/index.js',
                options: { sourceMap: true }
              },
              {
                loader: '/var/www/symfony/node_modules/sass-loader/dist/cjs.js',
                options: {
                  sourceMap: true,
                  sassOptions: { outputStyle: 'expanded' }
                }
              }
            ]
          },
          {
            use: [
              {
                loader: '/var/www/symfony/node_modules/mini-css-extract-plugin/dist/loader.js',
                options: {}
              },
              {
                loader: '/var/www/symfony/node_modules/css-loader/dist/cjs.js',
                options: {
                  sourceMap: true,
                  importLoaders: 0,
                  modules: { auto: [Function: auto] }
                }
              },
              {
                loader: '/var/www/symfony/node_modules/resolve-url-loader/index.js',
                options: { sourceMap: true }
              },
              {
                loader: '/var/www/symfony/node_modules/sass-loader/dist/cjs.js',
                options: {
                  sourceMap: true,
                  sassOptions: { outputStyle: 'expanded' }
                }
              }
            ]
          }
        ]
      },
      {
        test: /\.tsx?$/,
        exclude: /node_modules/,
        use: [
          {
            loader: '/var/www/symfony/node_modules/babel-loader/lib/index.js',
            options: {
              cacheDirectory: true,
              sourceType: 'unambiguous',
              presets: [
                [
                  '/var/www/symfony/node_modules/@babel/preset-env/lib/index.js',
                  {
                    modules: false,
                    targets: {},
                    useBuiltIns: false,
                    corejs: null
                  }
                ],
                [
                  '/var/www/symfony/node_modules/@babel/preset-react/lib/index.js',
                  { runtime: 'automatic' }
                ]
              ],
              plugins: []
            }
          },
          {
            loader: '/var/www/symfony/node_modules/ts-loader/index.js',
            options: { silent: true }
          }
        ]
      },
      {
        test: /\.svg$/i,
        issuer: /\.[jt]sx?$/,
        use: [ '@svgr/webpack' ]
      }
    ]
  },
  plugins: [
    MiniCssExtractPlugin {
      _sortedModulesCache: WeakMap { <items unknown> },
      options: {
        filename: '[name].css',
        ignoreOrder: false,
        experimentalUseImportModule: undefined,
        runtime: true,
        chunkFilename: '[name].css'
      },
      runtimeOptions: {
        insert: undefined,
        linkType: 'text/css',
        attributes: undefined
      }
    },
    DeleteUnusedEntriesJSPlugin { entriesToDelete: [] },
    WebpackManifestPlugin {
      options: {
        basePath: 'build/',
        fileName: 'manifest.json',
        filter: [Function: filter],
        generate: undefined,
        map: [Function (anonymous)],
        publicPath: null,
        removeKeyHash: /([a-f0-9]{32}\.?)/gi,
        seed: {},
        serialize: [Function: serialize],
        sort: null,
        transformExtensions: /^(gz|map)$/i,
        useEntryKeys: false,
        writeToFileEmit: true
      }
    },
    DefinePlugin {
      definitions: { 'process.env.NODE_ENV': '"development"' }
    },
    FriendlyErrorsWebpackPlugin {
      compilationSuccessInfo: { messages: [] },
      onErrors: undefined,
      shouldClearConsole: false,
      logLevel: 0,
      formatters: [
        [Function: format],
        [Function: format],
        [Function: format],
        [Function: format],
        [Function: format],
        [Function: format]
      ],
      transformers: [
        [Function: transform],
        [Function: transform],
        [Function: transform],
        [Function: transform],
        [Function (anonymous)],
        [Function: transform]
      ],
      previousEndTimes: {},
      reporter: BaseReporter {
        enabled: true,
        success: [Function (anonymous)],
        info: [Function (anonymous)],
        note: [Function (anonymous)],
        warn: [Function (anonymous)],
        error: [Function (anonymous)]
      }
    },
    AssetOutputDisplayPlugin {
      outputPath: 'public/build',
      friendlyErrorsPlugin: FriendlyErrorsWebpackPlugin {
        compilationSuccessInfo: { messages: [] },
        onErrors: undefined,
        shouldClearConsole: false,
        logLevel: 0,
        formatters: [
          [Function: format],
          [Function: format],
          [Function: format],
          [Function: format],
          [Function: format],
          [Function: format]
        ],
        transformers: [
          [Function: transform],
          [Function: transform],
          [Function: transform],
          [Function: transform],
          [Function (anonymous)],
          [Function: transform]
        ],
        previousEndTimes: {},
        reporter: BaseReporter {
          enabled: true,
          success: [Function (anonymous)],
          info: [Function (anonymous)],
          note: [Function (anonymous)],
          warn: [Function (anonymous)],
          error: [Function (anonymous)]
        }
      }
    },
    CopyPlugin {
      patterns: [
        { from: './assets/images', to: 'images' },
        { from: './assets/jslibs', to: 'jslibs' },
        { from: './assets/data', to: 'data' },
        { from: './assets/email', to: 'email' },
        { from: './assets/easyadmin', to: 'easyadmin' },
        { from: './assets/pdf', to: 'pdf' },
        { from: './assets/fonts', to: 'unbounce/[name][ext]' },
        { from: './assets/fonts', to: 'fonts' },
        {
          from: './node_modules/bootstrap/dist/js/bootstrap.bundle.min.js',
          to: 'bootstrap.bundle.min.js'
        }
      ],
      options: {}
    },
    EntryPointsPlugin {
      publicPath: '/build/',
      outputPath: '/var/www/symfony/public/build',
      integrityAlgorithms: []
    }
  ],
  optimization: { splitChunks: { chunks: 'async', cacheGroups: {} } },
  watchOptions: { ignored: /node_modules/ },
  devtool: 'inline-source-map',
  performance: { hints: false },
  stats: {
    hash: false,
    version: false,
    timings: false,
    assets: false,
    chunks: false,
    modules: false,
    reasons: false,
    children: false,
    source: false,
    errors: false,
    errorDetails: false,
    warnings: false,
    publicPath: false,
    builtAt: false
  },
  resolve: {
    extensions: [
      '.wasm',   '.mjs',
      '.js',     '.json',
      '.jsx',    '.vue',
      '.ts',     '.tsx',
      '.svelte'
    ],
    alias: {
      '@components': '/var/www/symfony/assets/react/components',
      '@atomic': '/var/www/symfony/assets/react/components/atomic',
      '@react': '/var/www/symfony/assets/react',
      '@images': '/var/www/symfony/assets/images',
      '@symfony/stimulus-bridge/controllers.json': '/var/www/symfony/assets/controllers.json'
    }
  },
  externals: [ { bootstrap: 'bootstrap' } ]
}

@Lyrkan
Copy link
Collaborator

Lyrkan commented Apr 11, 2025

Hey @nerdess,

Let's continue here instead of #701.

When you said that you tried using ?module but it didn't work, what was the issue exactly ? Did you have an error or were the styles simply not applied?

What happens if you remove the configureCssLoader call for your first example and change the code of your React component to:

import Toolbar from './toolbar/Toolbar';
import Grid from './grid/Grid';
import styles from './calendar.scss?module';

const Calendar = () => {
    return (
        <div className={ styles.tsCalendar }>
            <Toolbar />
            <Grid />
        </div>
    )
}

export default Calendar;

@nerdess
Copy link
Author

nerdess commented Apr 11, 2025

Yep, I kicked out configureCSSLoader() since it did not do any useful stuff.

Regarding your question with ?module:

First, because I use Typescript, I added some styles.d.ts so it would not complain about the ?module or .css/.scss in general:

declare module '*.scss' {
	const content: { [className: string]: string };
	export default content;
}

declare module '*.css' {
	const content: { [className: string]: string };
	export default content;
}

declare module '*.css?module' {
	const classes: { [key: string]: string };
	export default classes;
}

declare module '*.scss?module' {
	const classes: { [key: string]: string };
	export default classes;
}

Then going back to my React Component, I did this:

import styles from './calendar.scss?module'; //doesn not matter if I try .scss or .css files
console.log(styles);

The console.log is printing undefined and no style is applied.

When looking into my Encore.getWebpackConfig() I was wondering, why resourceQuery: /module/ is still using mini-css-extract-plugin as loader? Should it not rather use style-loader?!?

{
        resolve: { mainFields: [ 'style', 'main' ], extensions: [ '.css' ] },
        test: /\.(css)$/,
        oneOf: [
          {
            resourceQuery: /module/,
            use: [
              {
                loader: '/var/www/symfony/node_modules/mini-css-extract-plugin/dist/loader.js',
                options: {}
              },
              {
                loader: '/var/www/symfony/node_modules/css-loader/dist/cjs.js',
                options: {
                  sourceMap: true,
                  importLoaders: 0,
                  modules: { localIdentName: '[local]_[hash:base64:5]' }
                }
              }
            ]
          },
          {
            use: [
              {
                loader: '/var/www/symfony/node_modules/mini-css-extract-plugin/dist/loader.js',
                options: {}
              },
              {
                loader: '/var/www/symfony/node_modules/css-loader/dist/cjs.js',
                options: { sourceMap: true, importLoaders: 0, modules: false }
              }
            ]
          }
        ]
      },

@Lyrkan
Copy link
Collaborator

Lyrkan commented Apr 11, 2025

Here is a working example : https://github.com/Lyrkan/symfony-typescript-react-css-modules

It generates a CSS file similar to:

body {
  background-color: lightgray;
}

.hello_tdNlH {
  background-color: red;
}

By default the CSS loader uses named exports and does not seem to export default, hence the import * as styles from "../../styles/Hello.scss?module";.

If you want to use import styles from "../../styles/Hello.scss?module"; instead you will have to call configureCssLoader and set options.modules.namedExport to false, but both methods should work.

@nerdess
Copy link
Author

nerdess commented Apr 13, 2025

@Lyrkan I do not know how much to thank you. The example you put on Github was the perfect blueprint that I needed for my code forensics.

It turned out the problem was not my webpack config (!!!!), it was that the CSS which was generated through Javascript was not added to base.html.twig at all!

Before:

{{ encore_entry_script_tags('appJS', null, '_default', {defer: true}) }}

Now:

{{ encore_entry_script_tags('appJS', null, '_default', {defer: true}) }}
{{ encore_entry_link_tags('appJS') }}

Everything works like magic now. I am so happy!

@Lyrkan
Copy link
Collaborator

Lyrkan commented Apr 13, 2025

Glad you figured it out :)

@Lyrkan Lyrkan closed this as completed Apr 13, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants