Build: Vite and webpack
What the build has to do
Whatever the tool, the output must satisfy the Drupal side:
- One bundle per component, named after the component folder, written to the subtheme's
assets/(assets/<name>.js,assets/<name>.css), because the component'slibraryOverridespoints there. - React and ReactDOM are externals mapped to the globals
React/ReactDOM, and the UMD builds are copied toassets/react/(libraryframe/react, provided by the base theme, a subtheme does not copy it). - Shared helpers (
helpers.jsx,apiClient.js) are built once by the base theme and loaded by its libraries, not bundled into components. - CSS is extracted from the
import './x.scss'in the entry. - No JS dependency on jQuery or on Drupal internals except the globals
Drupal,once,drupalSettings.
Vite (what the scaffold uses)
The configuration lives once, in the base theme: vite.base.js. Subthemes do not have a vite.config.js, their npm run build runs frame build of the base theme, which calls the factory for the current folder:
js
createViteConfig({ root: process.cwd(), mode, entries: { global: './scss/global.scss' } }) // `global` only when the file existsA theme that needs more can add its own vite.config.js, see Base theme and subthemes.
Options are listed in Base theme and subthemes. What the factory does:
js
// entries: one per folder in components/ that has index.jsx (or index.js), plus your `entries`
build: {
outDir: 'assets',
emptyOutDir: true,
rollupOptions: {
input: entries,
output: { entryFileNames: '[name].js', chunkFileNames: '[name].js', assetFileNames: '[name][extname]' },
},
},
plugins: [
react({ jsxRuntime: 'classic' }),
reactFromGlobals(), // react, react-dom, react-dom/client → window.React / window.ReactDOM, also inside npm libraries
// runtime: true (base theme only) → also copies node_modules/react/umd/*.js into assets/react/
],
esbuild: { jsxInject: `import React from 'react'` }, // classic runtime: React is the global
css: { postcss: { plugins: [autoprefixer] } },Things worth knowing:
- Output is ES modules when there is more than one entry. Rollup splits tiny shared chunks (
react-dom.js,react-dom_client.js: shims for the externals). Scripts withimportstatements only run as modules, so component libraries useattributes: { type: module }. Module scripts are deferred and run beforeDOMContentLoaded, which is when Drupal attaches behaviors, so ordering is fine. The base theme runtime has no shared chunks, it is classic scripts. react-dom/clientis mapped toReactDOM: the UMD React 18ReactDOMobject hascreateRoot.jsxRuntime: 'classic'+jsxInject: JSX compiles toReact.createElementwith the global React.- Modes:
npm run watch= development (source maps),npm run build= production. - New component = no config change: the entries are read from
components/. - CSS names: the CSS imported by
index.jsxis emitted asassets/<entry>.css. - Dependencies: the Vite plugins, Babel and Testing Library packages come from the base theme's
node_modules(runnpm installin the base theme once). Your subtheme adds what its components import (rsuite,react-tippy...), plusvite,sassandjest. - Base theme build:
npm run distin the base theme builds its runtime intoassets/(React UMD, helpers, apiClient). The compiled files are not committed, releases build and attach them, see Releases.
Webpack in a subtheme
Vite is what the scaffold uses, but the contract above is tool independent. This webpack.config.cjs follows it for a subtheme. It was tested with webpack 5 on a generated subtheme: all components mounted in Drupal from its output.
bash
npm i -D webpack webpack-cli babel-loader css-loader sass-loader mini-css-extract-plugin @babel/core @babel/preset-react
npx webpack --mode production -c webpack.config.cjsjs
// webpack.config.cjs: the same contract as the Vite build (see "What the build has to do").
const path = require('path');
const fs = require('fs');
const webpack = require('webpack');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
// One entry per folder of components/ with an index.jsx.
const components = Object.fromEntries(
fs.readdirSync('components', { withFileTypes: true })
.filter((d) => d.isDirectory() && fs.existsSync(`components/${d.name}/index.jsx`))
.map((d) => [d.name, `./components/${d.name}/index.jsx`]),
);
module.exports = {
entry: { ...components, global: './scss/global.scss' },
output: { path: path.resolve(__dirname, 'assets'), filename: '[name].js', clean: true },
externals: { react: 'React', 'react-dom': 'ReactDOM', 'react-dom/client': 'ReactDOM' },
optimization: { splitChunks: false }, // one self-contained classic script per entry
module: {
rules: [
{ test: /\.jsx?$/, exclude: /node_modules/, use: { loader: 'babel-loader', options: { presets: [['@babel/preset-react', { runtime: 'classic' }]] } } },
{ test: /\.s?css$/, use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'] },
],
},
resolve: { extensions: ['.js', '.jsx'] },
plugins: [
new webpack.ProvidePlugin({ React: 'react' }), // classic JSX needs React in scope, 'react' is the external window.React
new MiniCssExtractPlugin({ filename: '[name].css' }),
],
};What differs from the Vite build:
optimization.splitChunks: falsegives one self-contained classic script per entry, so dropattributes: { type: module }from thelibraryOverrides(it also works with the attribute, a classic script is valid as a module).- The React runtime is not built here: the base theme provides it (
frame/react), so there is no UMD copy step. - Classic JSX needs
Reactin scope:ProvidePlugininjects the externalwindow.React. The automatic JSX runtime would importreact/jsx-runtime, which the UMD build does not have. - webpack also emits an empty
global.jsfor the SCSS-only entry, ignore it. @use 'frame/tokens'(the theme's design tokens in SCSS) is resolved by the shared Vite config only. With webpack, write the module to a file before the build,frame tokens --out scss/_tokens.scss, and@use 'tokens' as frame;.- Keep
assets/as the output folder, the libraries point there. Do not run both tools into the same folder. - Build and tests of the base theme itself stay on Vite. Subthemes can use any bundler.
Checklist when a component does not show up
ls assets/has<name>.jsand<name>.css.- The path in
libraryOverridesmatches (../../assets/<name>.js), anddrush crwas run after editing the yml. - Page source contains the script, is it
type="module"? If the console says "Cannot use import statement outside a module", it is not. React,ReactDOM,apiClientare defined: the component library depends onframe/react/react-api-client.- The mount element has the class your
once()selector uses.