diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f485395..5c310da2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## unreleased + +### pack, serve, eject + +* Added experimental support for the Vite bundler, enabled with the `--vite` option or the `ENACT_BUNDLER=vite` environment variable. + ## 7.3.3 (July 7, 2026) ### transpile diff --git a/commands/eject.js b/commands/eject.js index 10734c20..17e58865 100755 --- a/commands/eject.js +++ b/commands/eject.js @@ -50,6 +50,32 @@ const bareTasks = { test: 'jest --config config/jest/jest.config.js', 'test-watch': 'jest --config config/jest/jest.config.js --watch' }; +// Vite variants of the barebones setup (used with `--vite`). Vite copies `public/` +// automatically (no `cpy` step) and loads the generated root `vite.config.mjs`. +// Reuse the same `rimraf` pin as the webpack bare setup so the version lives in one place. +const bareDepsVite = {rimraf: bareDeps.rimraf}; +const bareTasksVite = { + serve: 'vite', + pack: 'vite build --mode development', + 'pack-p': 'vite build', + watch: 'vite build --watch --mode development', + clean: 'rimraf build dist', + lint: bareTasks.lint, + license: bareTasks.license, + test: bareTasks.test, + 'test-watch': bareTasks['test-watch'] +}; +// Bundler-driven scripts that understand `--vite`; used to steer a non-bare Vite eject. +const VITE_CAPABLE_SCRIPTS = ['serve', 'pack']; +// The Enact Vite config (config/vite.config.js) is a factory `(mode) => InlineConfig`, +// not the object/`{command, mode}` shape Vite's CLI expects. A bare Vite eject writes +// this thin root config to adapt it so `vite` / `vite build` work directly. +const VITE_ROOT_CONFIG = + "import {createRequire} from 'module';\n" + + "const require = createRequire(import.meta.url);\n" + + '// config/vite.config.js exports `(mode) => InlineConfig`; Vite calls with {command, mode}.\n' + + "const enactViteConfig = require('./config/vite.config.js');\n" + + "export default ({mode}) => enactViteConfig(mode || 'production');\n"; function displayHelp () { let e = 'node ' + path.relative(process.cwd(), __filename); @@ -62,6 +88,9 @@ function displayHelp () { console.log(' -b, --bare Abandon Enact CLI command enhancements'); console.log(' and eject into a a barebones setup (using'); console.log(' webpack, eslint, karma, etc. directly)'); + console.log(' --vite [Experimental] Use Vite instead of webpack:'); + console.log(' alone, points the serve/pack scripts at the'); + console.log(' Vite path; with --bare, emits a bare Vite setup'); console.log(' -v, --version Display version information'); console.log(' -h, --help Display help information'); console.log(); @@ -154,12 +183,15 @@ function copySanitizedFile ({src, dest}) { fs.writeFileSync(dest, data, {encoding: 'utf8'}); } -function configurePackage (bare) { +function configurePackage (bare, vite) { const own = require('../package.json'); const app = require(path.resolve('package.json')); const backup = JSON.stringify(app, null, 2) + os.EOL; const availScripts = fs.existsSync('./scripts') ? fs.readdirSync('./scripts').map(f => f.replace(/\.js$/, '')) : []; const enactCLI = new RegExp('enact (' + availScripts.join('|') + ')', 'g'); + // Select the webpack or Vite flavor of the barebones tasks/deps. + const tasks = vite ? bareTasksVite : bareTasks; + const deps = vite ? bareDepsVite : bareDeps; const eslintConfig = {extends: 'enact'}; const eslintIgnore = ['build/*', 'config/*', 'dist/*', 'node_modules/*', 'scripts/*']; const conflicts = []; @@ -182,9 +214,9 @@ function configurePackage (bare) { // Add any additional dependencies if (bare) { - Object.keys(bareDeps).forEach(key => { + Object.keys(deps).forEach(key => { console.log(` Adding ${chalk.cyan(key)} to devDependencies`); - app.devDependencies[key] = bareDeps[key]; + app.devDependencies[key] = deps[key]; }); } @@ -193,16 +225,20 @@ function configurePackage (bare) { // Update NPM task scripts const type = chalk.cyan('npm script'); Object.keys(app.scripts).forEach(key => { - if (bare && bareTasks[key]) { + if (bare && tasks[key]) { if (!conflicts.includes(type)) conflicts.push(type); - const bin = bareTasks[key].match(/^(?:node\s+)*(\S*)/); - const updated = (bin && bin[1]) || bareTasks[key]; + const bin = tasks[key].match(/^(?:node\s+)*(\S*)/); + const updated = (bin && bin[1]) || tasks[key]; console.log(` Updating npm task ${chalk.cyan(key)} to use ${chalk.cyan(updated)}`); - app.scripts[key] = bareTasks[key]; + app.scripts[key] = tasks[key]; } else if (!bare) { app.scripts[key] = app.scripts[key].replace(enactCLI, (match, name) => { - console.log(` Updating npm task ${chalk.cyan(key)} to use ` + chalk.cyan(`scripts/${name}.js`)); - return `node ./scripts/${name}.js`; + // In a non-bare Vite eject, steer the bundler-driven scripts down the + // Vite path so `npm run serve`/`pack` use Vite, not webpack. Only the + // commands that understand the flag (serve, pack) get it. + const viteFlag = vite && VITE_CAPABLE_SCRIPTS.includes(name) ? ' --vite' : ''; + console.log(` Updating npm task ${chalk.cyan(key)} to use ` + chalk.cyan(`scripts/${name}.js${viteFlag}`)); + return `node ./scripts/${name}.js${viteFlag}`; }); } }); @@ -259,7 +295,7 @@ function npmInstall () { }); } -function api ({bare = false} = {}) { +function api ({bare = false, vite = false} = {}) { if (bare) { assets.pop(); } @@ -270,9 +306,15 @@ function api ({bare = false} = {}) { console.log(chalk.cyan(`Copying files into ${process.cwd()}`)); assets.forEach(dir => !fs.existsSync(dir.dest) && fs.mkdirSync(dir.dest, {recursive: true})); files.forEach(copySanitizedFile); + // A bare Vite eject drives the Vite CLI directly, which loads a root + // config; write the adapter that wires it to config/vite.config.js. + if (bare && vite) { + console.log(` Adding ${chalk.cyan('vite.config.mjs')} to the project`); + fs.writeFileSync('vite.config.mjs', VITE_ROOT_CONFIG, {encoding: 'utf8'}); + } console.log(); console.log(chalk.cyan('Configuring package.json')); - const con = configurePackage(bare); + const con = configurePackage(bare, vite); console.log(); console.log(chalk.cyan('Running npm install...')); return npmInstall().then(() => { @@ -298,7 +340,7 @@ function api ({bare = false} = {}) { function cli (args) { const opts = minimist(args, { - boolean: ['bare', 'help'], + boolean: ['bare', 'vite', 'help'], alias: {b: 'bare', h: 'help'} }); if (opts.help) displayHelp(); @@ -307,7 +349,7 @@ function cli (args) { import('chalk').then(({default: _chalk}) => { chalk = _chalk; - api({bare: opts.bare}).catch(err => { + api({bare: opts.bare, vite: opts.vite}).catch(err => { console.error(chalk.red('ERROR: ') + err.message); process.exit(1); }); diff --git a/commands/pack.js b/commands/pack.js index cb937de1..0de2616f 100755 --- a/commands/pack.js +++ b/commands/pack.js @@ -20,6 +20,12 @@ const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages'); const printBuildError = require('react-dev-utils/printBuildError'); const webpack = require('webpack'); const {optionParser: app, mixins, configHelper: helper} = require('@enact/dev-utils'); +const viteFw = require('@enact/dev-utils/mixins/vite-framework'); +const viteIso = require('@enact/dev-utils/mixins/vite-isomorphic'); +const viteSnap = require('@enact/dev-utils/mixins/vite-snapshot'); +const {parseLocales} = require('@enact/dev-utils/plugins/PrerenderPlugin/parse-locales'); + +const {isViteBundler} = require('./vite-utils'); let chalk; let stripAnsi; @@ -52,6 +58,7 @@ function displayHelp () { console.log(' -c, --custom-skin Build with a custom skin'); console.log(' --no-linting Build without code linting'); console.log(' --no-animation Build without effects such as animation and shadow'); + console.log(' --vite [Experimental] Build with Vite instead of webpack'); console.log(' --stats Output bundle analysis file'); console.log(' --verbose Verbose log build details'); console.log(' -v, --version Display version information'); @@ -113,7 +120,7 @@ function details (err, stats, output) { console.log( chalk.yellow( '\nTreating warnings as errors because process.env.CI = true. \n' + - 'Most CI servers set it automatically.\n' + 'Most CI servers set it automatically.\n' ) ); return new Error(filteredWarnings.join('\n\n')); @@ -130,7 +137,7 @@ function details (err, stats, output) { console.log( chalk.yellow( 'NOTICE: This build contains debugging functionality and may run' + - ' slower than in production mode.' + ' slower than in production mode.' ) ); } @@ -186,7 +193,7 @@ function printErrorDetails (err, handler) { console.log( chalk.yellow( 'Compiled with the following type errors (you may want to check ' + - 'these before deploying your app):\n' + 'these before deploying your app):\n' ) ); printBuildError(err); @@ -197,6 +204,251 @@ function printErrorDetails (err, handler) { } } + +// Build the shared Enact framework bundle (react + ilib + all @enact) as reusable ESM +// addressed by an import map, plus a manifest. Vite counterpart to webpack `pack --framework`. +async function viteFramework (opts) { + const {createRequire} = require('module'); + const {build: viteBuildApi} = require('vite'); + const appRequire = createRequire(path.join(app.context, 'package.json')); + + const specs = viteFw.enumerateSpecifiers(app.context, {polyfill: opts['externals-polyfill']}); + // Building --framework inside a theme repo: also include the theme's own components + // (webpack's `libraries.push('.')`), which aren't in the repo's node_modules/@enact. + const self = viteFw.enumerateSelfSpecs(app.context); + const allSpecs = self ? specs.concat(self.specs.filter(s => !specs.includes(s))) : specs; + const selfSet = self ? new Set(self.specs) : null; + const srcDir = path.join(app.context, '.enact-framework-src'); + const {input, names} = viteFw.writeWrappers(allSpecs, srcDir, appRequire, selfSet); + + const configFactory = require('../config/vite.config'); + const config = configFactory(opts.production ? 'production' : 'development', !opts.linting); + const outDir = opts.output ? path.resolve(opts.output) : path.resolve('./dist'); + viteFw.applyFramework(config, {input, outDir, selfAlias: self && {find: self.name, replacement: self.root}}); + // --no-minify/--verbose/--stats still apply to the framework build. + mixins.applyVite(config, opts); + + console.log(`Creating the Enact framework bundle (${Object.keys(input).length} modules)...`); + await viteBuildApi(config); + const manifest = viteFw.writeManifest(outDir, names); + fs.removeSync(srcDir); + console.log( + chalk.green(`Framework compiled successfully. (${Object.keys(manifest.imports).length} specifiers)`) + ); +} + +// Isomorphic (prerendered) Vite build. Client build (hydrateRoot) + a real `vite build --ssr` +// of the app entry, then per-locale server render (FileXHR for iLib data) and assembly into +// the webpack-compatible output (fallback index.html + deduped index..html + +// locale-map.json + per-locale webOS appinfo). Vite counterpart to webpack `pack --isomorphic`. +async function viteIsomorphic (opts) { + const {createRequire} = require('module'); + const {build: viteBuildApi} = require('vite'); + const appRequire = createRequire(path.join(app.context, 'package.json')); + const configFactory = require('../config/vite.config'); + + const locales = opts.locales ? parseLocales(app.context, opts.locales) || ['en-US'] : ['en-US']; + const outDir = opts.output ? path.resolve(opts.output) : path.resolve('./dist'); + const serverEntry = path.resolve(opts.entry || app.entry || path.join(app.context, 'src/index.js')); + const ssrOut = path.join(app.context, '.enact-ssr'); + + console.log( + opts.snapshot ? + `Creating a V8 snapshot production build (${locales.length} locale(s))...` : + `Creating an isomorphic production build (${locales.length} locale(s))...` + ); + + // 1) Client build (isomorphic ON → the app entry uses hydrateRoot). + // --externals-polyfill: keep the entry's `import 'core-js/stable'` un-expanded + // in the CLIENT build so it can be externalized (see viteBuild). Cleared again + // before the SSR config below — the SSR build always bundles. + if (opts.externals && opts['externals-polyfill']) process.env.ENACT_VITE_EXTERNAL_POLYFILL = 'true'; + const clientConfig = configFactory( + opts.production ? 'production' : 'development', + !opts.linting, + opts['content-hash'], + true /* isomorphic */, + !opts.animation, + !opts['split-css'], + opts['ilib-additional-path'], + opts.locales + ); + if (opts.output) clientConfig.build.outDir = outDir; + // --externals: externalize the shared framework from the CLIENT build (browser loads it via + // import map). The SSR build below always bundles @enact so it can render. CSS-module hashes + // stay consistent because both reuse the factory (same rootContext) as the framework build. + const collected = new Set(); + let manifest = null; + if (opts.externals) { + manifest = viteFw.readManifest(path.resolve(opts.externals)); + viteFw.applyExternals(clientConfig, collected, manifest, {polyfill: opts['externals-polyfill']}); + } + // --snapshot: build the client as a self-contained UMD bundle (App global) that mksnapshot + // can snapshot. Implies isomorphic; --snapshot + --externals is unsupported (the snapshot + // must embed @enact, not externalize it), matching webpack (`opts.snapshot && !opts.externals`). + if (opts.snapshot && !opts.externals) { + viteSnap.applySnapshotBuild(clientConfig, {context: app.context, appEntry: serverEntry}); + } + mixins.applyVite(clientConfig, opts); + + // 2) SSR build config (Node-loadable CJS whose default export is the app element). + delete process.env.ENACT_VITE_EXTERNAL_POLYFILL; + const ssrConfig = configFactory(opts.production ? 'production' : 'development', true, false, true); + viteIso.applySsrBuild(ssrConfig, {serverEntry, outDir: ssrOut}); + + // The client and SSR builds are independent (separate configs, separate + // outDirs; all shared setup — chdir, env, the generated combined entry — + // happens above at config-creation time), so run them concurrently instead + // of back to back. Profiling showed the sequential form was the isomorphic + // path's structural penalty vs webpack, which prerenders from a single + // compilation. + await Promise.all([viteBuildApi(clientConfig), viteBuildApi(ssrConfig)]); + + // Inject the framework import map (+ shared stylesheet) into the client index.html BEFORE + // the isomorphic assembly transforms it into the fallback/variant files. + if (opts.externals) { + let base = opts['externals-public']; + if (!base) { + fs.copySync(path.resolve(opts.externals), path.join(outDir, 'framework'), {dereference: true}); + base = './framework'; + } + viteFw.injectHtml(path.join(outDir, 'index.html'), manifest, collected, base); + } + + // 3) Per-locale prerender. Load the SSR bundle fresh for each locale so iLib re-initializes. + const bundlePath = path.join(ssrOut, 'app.server.cjs'); + const ssrRequire = createRequire(path.join(ssrOut, 'noop.js')); + const {renderToString} = appRequire('react-dom/server'); + const load = () => { + Object.keys(ssrRequire.cache || {}).forEach(k => { + if (k.startsWith(ssrOut)) delete ssrRequire.cache[k]; + }); + const mod = ssrRequire(bundlePath); + // eslint-disable-next-line no-undefined + return mod && mod.default !== undefined ? mod.default : mod; + }; + const {prerenders, attr, aliasOf} = viteIso.prerender({ + locales, + load, + renderToString, + fontGenerator: app.fontGenerator + }); + + // 4) Assemble HTML + locale-map, then (5) webOS per-locale appinfo. + const {localeMap} = viteIso.assemble({ + outDir, locales, prerenders, attr, aliasOf, + screenTypes: app.screenTypes || [], + snapshot: !!opts.snapshot + }); + viteIso.writeAppinfo({outDir, locales, localeMap}); + + fs.removeSync(ssrOut); + console.log( + chalk.cyan(`Prerendered ${locales.length} locale(s) into ${prerenders.length} variant(s).`) + ); + + // 6) V8 snapshot: run mksnapshot against the UMD main.js and record the blob in appinfo. + // Requires the webOS `V8_MKSNAPSHOT` toolchain; without it the build still succeeds and + // the startup script falls back to loading main.js (classic '; + return { + name: 'enact-dev-globals', + apply: 'serve', + transformIndexHtml (html) { + return /<\/head>/i.test(html) ? html.replace(/<\/head>/i, `${script}`) : script + html; + } + }; +} + +// --- babel transform cache -------------------------------------------------- +// @vitejs/plugin-react has no equivalent of babel-loader's `cacheDirectory`, so +// every build re-runs babel-preset-enact over the app plus all raw `@enact/*` +// source (~380 files; measured 2.6-4.3s per production build, and it is also +// why a "cached" Vite build barely improves on a cold one). This wraps the +// react plugin's `transform` hook with a memory+disk cache keyed on the *input +// code* plus a config signature — content-keyed, so watch-mode edits simply +// miss and re-transform, and prior plugins' output changes invalidate +// naturally. Entries are only stored for successful transforms; any cache I/O +// failure falls through to a real transform. +const BABEL_PRESET_ENACT_MTIME = (() => { + try { + return fs.statSync(require.resolve('babel-preset-enact')).mtimeMs; + } catch (e) { + return 0; + } +})(); + +function wrapReactBabelCache (reactPlugins, cacheDir, baseSignature) { + const nodeCrypto = require('crypto'); + const sha1 = s => nodeCrypto.createHash('sha1').update(s).digest('hex'); + let diskOk = true; + try { + fs.mkdirSync(cacheDir, {recursive: true}); + } catch (e) { + diskOk = false; + } + const mem = new Map(); + const plugins = [reactPlugins].flat(Infinity).filter(Boolean); + const target = plugins.find(p => p && p.name === 'vite:react-babel'); + if (!target) return plugins; + const hook = target.transform; + const isObj = hook && typeof hook === 'object' && typeof hook.handler === 'function'; + const orig = isObj ? hook.handler : hook; + if (typeof orig !== 'function') return plugins; + + const cachedTransform = function (code, id, options) { + // Only plain file ids; leave virtual/queried ids to the real transform. + if (typeof code !== 'string' || !id || id.includes('\0') || id.includes('?')) { + return orig.call(this, code, id, options); + } + const key = sha1(`${baseSignature}|${id}|${options && options.ssr ? 'ssr' : 'web'}|${code}`); + const hit = mem.get(key); + if (hit) return hit; + if (diskOk) { + try { + const entry = JSON.parse(fs.readFileSync(path.join(cacheDir, key + '.json'), 'utf8')); + mem.set(key, entry); + return entry; + } catch (e) { + // disk miss — transform for real below + } + } + const finish = result => { + if (result && typeof result === 'object' && typeof result.code === 'string') { + const entry = {code: result.code, map: result.map || null}; + mem.set(key, entry); + if (diskOk) { + try { + const dest = path.join(cacheDir, key + '.json'); + const tmp = `${dest}.${process.pid}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(entry)); + fs.renameSync(tmp, dest); + } catch (e) { + // best effort + } + } + return entry; + } + return result; + }; + const r = orig.call(this, code, id, options); + return r && typeof r.then === 'function' ? r.then(finish) : finish(r); + }; + + if (isObj) target.transform = {...hook, handler: cachedTransform}; + else target.transform = cachedTransform; + return plugins; +} + +function enactNodePolyfillOptimizerFixPlugin () { + const BUILTINS = new Set(require('module').builtinModules); + return { + name: 'enact-node-polyfill-optimizer-fix', + configResolved (config) { + const esbuildOptions = config.optimizeDeps && config.optimizeDeps.esbuildOptions; + const plugins = esbuildOptions && esbuildOptions.plugins; + if (!Array.isArray(plugins)) return; + const index = plugins.findIndex(p => p && p.name === 'node-stdlib-browser-alias'); + if (index === -1) return; + + const map = {}; + for (const entry of config.resolve.alias || []) { + if (!entry || typeof entry.find !== 'string' || typeof entry.replacement !== 'string') continue; + const name = entry.find.replace(/^node:/, ''); + if (!BUILTINS.has(name)) continue; + map[entry.find] = NODE_POLYFILL_FROM_CLI.test(entry.replacement) ? + require.resolve(entry.replacement) : + entry.replacement; + } + if (!Object.keys(map).length) return; + try { + plugins[index] = require('node-stdlib-browser/helpers/esbuild/plugin')(map); + } catch (e) { + // Leave the original in place; the scan degrades but still serves. + } + } + }; +} + +// Webpack parity for `resolve.modules: [path.resolve('./node_modules'), 'node_modules']`: +// webpack adds the APP-ROOT node_modules as a global resolution root, so a bare specifier +// imported from ANY file in the graph resolves there — even source pulled in from a +// sibling directory outside the app root (e.g. the `all-samples` aggregate imports +// `../../../pattern-locale-switching/src/main`, whose code does `import {Provider} from +// 'react-redux'`; `react-redux` is a dep of all-samples, not of the sibling). Vite/Rollup +// only walk up from the importing file, so such a sibling's bare deps go unresolved. This +// plugin restores the app-root fallback: when normal resolution fails for a bare specifier, +// retry from `/node_modules`. +function enactAppModulesResolverPlugin (appContext) { + const appModules = path.join(appContext, 'node_modules'); + return { + name: 'enact-app-modules-resolver', + async resolveId (source, importer, options) { + // Only bare specifiers; skip relative/absolute/virtual ids and entries. + if (!importer || /^[./]/.test(source) || source.startsWith('\0') || path.isAbsolute(source)) { + return null; + } + // Act only as a fallback: let the normal pipeline resolve first. + const resolved = await this.resolve(source, importer, {...options, skipSelf: true}); + if (resolved) return resolved; + try { + return require.resolve(source, {paths: [appModules]}); + } catch (e) { + return null; + } + } + }; +} + +const FORCE_CSS_STYLE_RE = /\.(?:css|less|s[ac]ss)(?:\?.*)?$/; +const FORCE_CSS_MODULE_RE = /\.module\.(?:css|less|s[ac]ss)(?:\?.*)?$/; + +// The Enact `forceCSSModules` build option makes ALL css/less/scss behave as CSS +// modules (scoped), not just `*.module.*`, matching the webpack build, whose +// non-module style rules use `modules:{getLocalIdent}` (no `mode:'icss'`) when the +// option is set. Vite decides module-ness purely from the `.module.` filename infix +// (cssModuleRE) with no override hook, so we resolve each non-module style import and +// redirect it to a virtual id that carries a `.module` infix. The virtual id keeps the +// real directory (so LESS `@import`/`url()` still resolve) and `load` serves the real +// file's contents. `virtualToReal` also lets `generateScopedName` recover the real +// path for the ident hash (webpack parity); genuine `*.module.*` files are untouched. +function enactForceCSSModulesPlugin (virtualToReal) { + return { + name: 'enact-force-css-modules', + enforce: 'pre', + async resolveId (source, importer, options) { + if (!FORCE_CSS_STYLE_RE.test(source) || FORCE_CSS_MODULE_RE.test(source)) return null; + const resolved = await this.resolve(source, importer, {...options, skipSelf: true}); + if (!resolved || resolved.external || FORCE_CSS_MODULE_RE.test(resolved.id)) return resolved; + // Inject `.module` before the extension, preserving the directory + any query. + const virtual = resolved.id.replace(/(\.(?:css|less|s[ac]ss))(\?.*)?$/, '.module$1$2'); + virtualToReal.set(virtual.split('?')[0], resolved.id.split('?')[0]); + return Object.assign({}, resolved, {id: virtual}); + }, + load (id) { + const real = virtualToReal.get(id.split('?')[0]); + if (!real) return null; + // Watch the real file so edits invalidate the virtual module (dev HMR). + this.addWatchFile(real); + return fs.readFileSync(real, 'utf8'); + } + }; +} + +// True for a style file that Vite will NOT treat as a CSS module: a css/less/scss +// without the `.module.` infix, imported normally (not `?raw`/`?url`/`?inline`, which +// Vite already gives a default export of their own). +function isPlainStyleId (id) { + const [file, query = ''] = String(id).split('?'); + if (!FORCE_CSS_STYLE_RE.test(file) || FORCE_CSS_MODULE_RE.test(file)) return false; + return !/(?:^|&)(?:raw|url|inline)(?:&|=|$)/.test(query); +} + +// ICSS interop for non-`*.module.*` CSS — the webpack `modules:{mode:'icss'}` behaviour. +// Enact apps conventionally do `import css from './App.less'` on a PLAIN (non-module) +// stylesheet and hand the map to `kind({styles:{css, className:'app'}})`. Under webpack, +// css-loader in `icss` mode leaves the class names global but still emits a default +// export (the ICSS `:export` locals, usually `{}`), so the import resolves and +// `classnames/bind` falls back to the literal global class name. Vite emits no default +// export for plain CSS at build time, so the same import is a hard error: +// "default" is not exported by "src/App/App.less" +// These two plugins restore parity WITHOUT scoping anything (scoping stays webpack- +// identical: plain CSS remains global; only `forceCSSModules` scopes it): +// 1. `enact-icss-extract` (normal order → runs after vite: css has compiled LESS/SCSS +// to CSS, before vite:css-post builds the JS proxy): lifts `:export {…}` blocks out +// of the compiled CSS into a locals map, and strips them from the emitted CSS +// (css-loader does the same; `:export` is not valid CSS for a browser). +// 2. `enact-icss-default-export` (post order → runs after vite:css-post): appends +// `export default ` when the proxy has none. Anything that already has a +// default export (dev's CSS-string proxy, `?inline`, `?url`, `?raw`) is left alone. +function enactICSSInteropPlugins () { + const icssExports = new Map(); + const key = id => String(id).split('?')[0]; + return [ + { + name: 'enact-icss-extract', + transform (code, id) { + if (!isPlainStyleId(id)) return null; + const locals = {}; + const stripped = code.replace(/:export\s*\{([^}]*)\}/g, (match, body) => { + body.split(';').forEach(decl => { + const at = decl.indexOf(':'); + if (at === -1) return; + const name = decl.slice(0, at).trim(); + if (name) locals[name] = decl.slice(at + 1).trim(); + }); + return ''; + }); + icssExports.set(key(id), locals); + return stripped === code ? null : {code: stripped, map: {mappings: ''}}; + } + }, + { + name: 'enact-icss-default-export', + enforce: 'post', + transform (code, id) { + if (!isPlainStyleId(id) || /(?:^|[;\s])export\s+default\s/.test(code)) return null; + const locals = icssExports.get(key(id)) || {}; + return {code: code + '\nexport default ' + JSON.stringify(locals) + ';\n', map: {mappings: ''}}; + } + } + ]; +} + +// Non-browser iLib platform loaders (`./lib/ilib-qt|rhino|ringo|node|….js`) and +// their `*Loader.js` helpers. iLib selects these via runtime platform detection; +// the browser branch never reaches them, but bundlers try (and fail) to resolve +// them statically. Webpack sidestepped this with ILibPlugin + WebpackLoader; here +// we neutralize them in both engines (Rollup build + esbuild dev optimizer). +const ILIB_LOADER_RE = /(?:[/\\]|^\.\/lib\/)ilib-[\w-]+\.js$|(?:Node|Rhino|Qt|Ringo)Loader(?:\.js)?$/; + +// Catch-all `assetsInclude` regex mirroring webpack's `asset/resource` fallthrough: +// any file whose extension is NOT code (js/ts/jsx…), markup (html/ejs), JSON, a +// stylesheet, or wasm/sourcemap is emitted as a file asset, so `import cfg from +// './analytics.cfg'` resolves to the emitted file's URL instead of Rollup trying to +// parse the file as JavaScript. +const ASSET_CATCHALL_RE = /\.(?!(?:m?[jt]sx?|c[jt]s|json5?|html?|ejs|css|less|s[ac]ss|styl|wasm|map)$)[a-z0-9_-]+$/i; + +// esbuild plugin (dev dependency optimizer) that stubs the iLib loaders to empty. +const ilibStubEsbuildPlugin = { + name: 'enact-ilib-loader-stub', + setup (build) { + build.onResolve({filter: ILIB_LOADER_RE}, args => ({path: args.path, namespace: 'enact-ilib-stub'})); + build.onLoad({filter: /.*/, namespace: 'enact-ilib-stub'}, () => ({contents: 'module.exports = {};', loader: 'js'})); + } +}; + +// esbuild plugin (dev dependency optimizer) that runs babel-preset-enact on +// `@enact/*` source. @enact packages ship raw, unbuilt source as their `main` +// (JSX-in-.js, decorators, and proposals like `export default from 'ilib'`) that +// esbuild's optimizer cannot parse. The Rollup build transforms them via +// @vitejs/plugin-react; this does the equivalent for pre-bundling. ESM is +// preserved (caller.supportsStaticESM) so esbuild can still bundle/tree-shake. +// Location of the generated combined entry, *relative to the app's +// node_modules*. Single source of truth: `createCombinedEntry` writes the file +// here, and the two filters below key off this same path. Keep them all derived +// from this constant so they can't drift. +const ENTRY_CACHE_SUBDIR = ['.cache', 'enact-vite']; + +// Character class matching either path separator, for regexes built below. +const SEP = '[\\\\/]'; + +// `.cache[\\/]enact-vite`, with the dot escaped, for use inside a path regex. +const ENTRY_CACHE_PATTERN = ENTRY_CACHE_SUBDIR.map(seg => seg.replace(/\./g, '\\.')).join(SEP); + +// Which files babel-preset-enact runs on. Mirrors webpack's +// `exclude: /node_modules.(?!@enact)/` — transpile everything except non-@enact +// node_modules — with one addition: the generated combined entry. That entry +// does `import 'core-js/stable'`, and babel-preset-enact's `useBuiltIns: 'entry'` +// is what rewrites it into just the polyfills the app's browserslist needs. +// Left excluded (it lives under node_modules) the import survives verbatim and +// Rollup pulls in the whole core-js stable set — measured on qa-a11y: 483 +// core-js modules instead of webpack's 77. +const babelTransformFilter = new RegExp(`${SEP}node_modules${SEP}(?!@enact${SEP}|${ENTRY_CACHE_PATTERN}${SEP})`); + +const ENACT_BABEL_OPTIMIZE_FILTER = new RegExp( + `${SEP}@enact${SEP}.*\\.(?:jsx?|mjs)$|${SEP}${ENTRY_CACHE_PATTERN}${SEP}index\\.js$` +); + +const enactBabelEsbuildPlugin = { + name: 'enact-babel-optimize', + setup (build) { + let babel; + const preset = require.resolve('babel-preset-enact'); + // `@enact/*` source, plus the generated combined entry — the latter must be + // transformed here too so the scanner expands `import 'core-js/stable'` into + // the same per-feature imports the request-time transform produces. If the + // two disagree, the browser requests core-js specifiers the optimizer never + // pre-bundled, forcing a re-optimize that 504s the in-flight page + // ("Outdated Optimize Dep") and leaves the app blank until a manual reload. + build.onLoad({filter: ENACT_BABEL_OPTIMIZE_FILTER}, async args => { + // iLib data/loaders under @enact/i18n are not Enact source. Leave them + // to esbuild (and the loader stub) rather than paying babel on big files. + if (/[\\/]ilib[\\/]/.test(args.path)) return null; + babel = babel || require('@babel/core'); + const source = fs.readFileSync(args.path, 'utf8'); + const result = await babel.transformAsync(source, { + babelrc: false, + configFile: false, + filename: args.path, + caller: {name: 'vite-optimize', supportsStaticESM: true, supportsDynamicImport: true}, + presets: [preset] + }); + return {contents: result.code, loader: 'js'}; + }); + } +}; + +// LESS `~specifier` imports (e.g. `@import '~@enact/ui/styles/core.less'`) are a +// webpack/less-loader convention that resolves the specifier from node_modules. +// Vite's LESS has no such resolver, so provide a custom Less FileManager that +// strips the `~` and resolves via Node module resolution (with sensible LESS +// extension fallbacks). Mirrors less-loader's `~` behavior. +function lessTildeImportPlugin (context) { + return { + install (less, pluginManager) { + class TildeFileManager extends less.FileManager { + supports (filename) { + return filename.charAt(0) === '~'; + } + supportsSync () { + return false; + } + loadFile (filename, currentDirectory, options, environment) { + const spec = filename.slice(1); + const paths = [currentDirectory, context].filter(Boolean); + const candidates = [spec, spec + '.less', spec + '/index.less', spec + '.css']; + let resolved; + for (const candidate of candidates) { + try { + resolved = require.resolve(candidate, {paths}); + break; + } catch (e) { + // try next candidate + } + } + if (!resolved) { + return Promise.reject({type: 'File', message: `'${filename}' wasn't found (tilde-resolve).`}); + } + return super.loadFile(resolved, currentDirectory, options, environment); + } + } + pluginManager.addFileManager(new TildeFileManager()); + } + }; +} + + +// Webpack's entry is `[polyfills, appMain]`, bundled into a single `main` chunk. +// Rollup has no array-concatenation entry, so we generate a tiny combined entry +// module (in the build cache, not the source tree) that imports each in order. +// Absolute-path targets are imported by a relative path; bare specifiers (e.g. +// `core-js/stable`) are emitted as-is so Vite resolves + pre-bundles them. +function createCombinedEntry (context, targets) { + const dir = path.join(context, 'node_modules', ...ENTRY_CACHE_SUBDIR); + fs.mkdirSync(dir, {recursive: true}); + const file = path.join(dir, 'index.js'); + const body = + targets + .map(target => { + if (!path.isAbsolute(target)) return `import ${JSON.stringify(target)};`; + let rel = path.relative(dir, target).replace(/\\/g, '/'); + if (!rel.startsWith('.')) rel = './' + rel; + return `import ${JSON.stringify(rel)};`; + }) + .join('\n') + '\n'; + fs.writeFileSync(file, body); + return file; +} + +// Mirrors the webpack.config.js factory signature, plus a trailing `locales` +// argument (Vite-specific) for iLib locale filtering (webpack threads `-l` +// through the isomorphic mixin instead). +module.exports = function ( + env, + noLinting = false, + contentHash = false, + isomorphic = false, + noAnimation = false, + noSplitCSS = false, + ilibAdditionalResourcesPath, + locales +) { + // Lazy-require so the CLI still runs without vite installed for the webpack path. + const react = require('@vitejs/plugin-react').default || require('@vitejs/plugin-react'); + + process.chdir(app.context); + require('./dotenv').load(app.context); + app.setEnactTargetsAsDefault(); + + const useTypeScript = fs.existsSync('tsconfig.json'); + const useTailwind = fs.existsSync(path.join(app.context, 'tailwind.config.js')); + + process.env.NODE_ENV = env || process.env.NODE_ENV; + const isEnvProduction = process.env.NODE_ENV === 'production'; + const GENERATE_SOURCEMAP = process.env.GENERATE_SOURCEMAP || (isEnvProduction ? 'false' : 'true'); + const shouldUseSourceMap = GENERATE_SOURCEMAP !== 'false'; + + // Resolve the concrete app entry file (webpack resolves the package dir to its main), + // then build a combined entry that loads core-js polyfills first, then the app. + // The CLI's `polyfills.js`/`corejs-proxy.js` are CommonJS (`require('core-js/stable')`) + // which the webpack build transpiles but Vite's browser ESM can't run; so we import + // `core-js/stable` directly as an ESM bare specifier (Vite pre-bundles the CJS→ESM), + // aliased below to the CLI's copy since apps don't depend on core-js directly. + const appEntry = require.resolve(app.context); + const entry = createCombinedEntry(app.context, ['core-js/stable', appEntry]); + const coreJsDir = path.dirname(require.resolve('core-js/package.json')); + + // --externals-polyfill (pack.js sets the env var before creating this config): + // the shared framework provides core-js, and its externalization requires the + // combined entry's `import 'core-js/stable'` to survive babel UN-expanded so + // the rollup `external` hook can match it as a single bare specifier (the + // core-js alias is dropped in that mode — see vite-framework applyExternals). + // Excluding the generated entry from babel disables the useBuiltIns:'entry' + // expansion for this build only; app and @enact source transpile as usual. + const externalPolyfill = process.env.ENACT_VITE_EXTERNAL_POLYFILL === 'true'; + const babelExclude = externalPolyfill ? + new RegExp(`${SEP}node_modules${SEP}(?!@enact${SEP})`) : + babelTransformFilter; + + // Maps `forceCSSModules` virtual `.module` ids back to their real style files, so + // `generateScopedName` can hash on the real path (see enactForceCSSModulesPlugin). + const forcedCSSVirtual = new Map(); + + // Enumerate the `@enact/*` packages installed in the app so they can be deduped + // (Vite `resolve.dedupe` takes exact names, not globs). Apps like the aggregate + // `all-samples` import source from many sibling packages, each with its own + // node_modules and thus its own copy of every `@enact/*` dep — deduping collapses + // them to one copy, cutting duplicate dependency optimization and bundle bloat. + const enactDir = path.join(app.context, 'node_modules', '@enact'); + const enactPackages = fs.existsSync(enactDir) ? + fs + .readdirSync(enactDir) + .filter(name => !name.startsWith('.') && fs.statSync(path.join(enactDir, name)).isDirectory()) + .map(name => '@enact/' + name) : + []; + + const postcssPlugins = getPostCssPlugins({useTailwind}); + + // Backward-compatibility ilib alias, matching webpack.config.js. + const ilibAlias = fs.existsSync(path.join(app.context, 'node_modules', '@enact', 'i18n', 'ilib')) ? + {ilib: '@enact/i18n/ilib'} : + {'@enact/i18n/ilib': 'ilib'}; + + return { + root: app.context, + base: app.publicUrl || '/', + mode: isEnvProduction ? 'production' : 'development', + clearScreen: false, + logLevel: 'warn', + // Vite copies `/public` into the build output automatically (webpack: copyPublicFolder). + publicDir: 'public', + // Treat unknown non-code extensions (e.g. `.cfg`) as emitted file assets, matching + // webpack's catch-all `asset/resource` loader + assetsInclude: ASSET_CATCHALL_RE, + define: { + 'process.env.NODE_ENV': JSON.stringify(isEnvProduction ? 'production' : 'development'), + 'process.env.PUBLIC_URL': JSON.stringify(app.publicUrl || ''), + // Isomorphic build selects hydrateRoot vs createRoot; animation gate. + ENACT_PACK_ISOMORPHIC: JSON.stringify(!!isomorphic), + ENACT_PACK_NO_ANIMATION: JSON.stringify(!!noAnimation) + }, + resolve: { + extensions: ['.js', '.mjs', '.jsx', '.ts', '.tsx', '.json'].filter( + ext => useTypeScript || !ext.includes('ts') + ), + // Array form so we can mix exact aliases with the regex `~` stripper. + alias: [ + ...Object.entries( + Object.assign( + {'react-is': path.dirname(require.resolve('react-is/package.json'))}, + // Resolve `core-js` (imported by the generated entry) to the CLI's copy, + // since apps don't depend on it directly. Also dedupes @enact's core-js. + {'core-js': coreJsDir}, + ilibAlias, + app.alias + ) + ).map(([find, replacement]) => ({find, replacement})), + // Strip the leading `~` from CSS `@import '~pkg'` so Vite resolves the bare + // specifier from node_modules (LESS `~` is handled by lessTildeImportPlugin). + {find: /^~/, replacement: ''} + ], + // Force a single copy of React across the app and all dependencies. Without this, + // Vite pre-bundling resolves multiple physical react copies and mixing + // components across them triggers "Invalid hook call / more than one copy + // of React". Webpack avoids this via single-tree resolution + exposing + // React on global in the isomorphic path. + dedupe: [ + 'react', + 'react-dom', + 'react/jsx-runtime', + 'react/jsx-dev-runtime', + // Shared libraries that the Enact stack ships a copy of inside *each* + // `@enact/*` package, plus iLib (which exists both top-level and nested + // under `@enact/i18n/ilib`). Without deduping, each importer pulls in its + // own copy: measured on qa-a11y, 22 redundant iLib modules and 12 + // prop-types ones. They are pinned to identical versions across the stack + // (ramda 0.32.0, classnames 2.5.1, warning 4.0.3, invariant 2.2.4), so + // collapsing them to one copy is safe. + 'ilib', + 'ramda', + 'prop-types', + 'classnames', + 'warning', + 'invariant', + ...enactPackages + ], + // Don't follow symlinks to their real paths (matches webpack `symlinks: false`). + preserveSymlinks: true + }, + css: { + devSourcemap: shouldUseSourceMap, + postcss: {plugins: postcssPlugins}, + // Vite treats *.module.* as CSS modules automatically. We only override the + // scoped-name generation to match the webpack cssModuleIdent output. + // cssModuleIdent expects a webpack-style loader context with `resourcePath` + // and `rootContext` (used for the ident hash); `localIdentName` is ignored. + // The result is sanitized to a valid CSS identifier: for nested @enact deps + // (e.g. @enact/limestone/node_modules/@enact/ui/…) the derived name embeds a + // literal `@`, which is invalid unescaped in a class selector. The trailing + // hash keeps names unique, so collapsing invalid chars to `_` is safe. + modules: { + generateScopedName (name, filename) { + // For `forceCSSModules` virtual ids, hash on the real path (webpack parity); + // genuine `*.module.*` files pass through unchanged. + const resourcePath = forcedCSSVirtual.get(filename.split('?')[0]) || filename; + const ident = getLocalIdent({resourcePath, rootContext: app.context}, null, name); + return ident.replace(/[^a-zA-Z0-9_-]/g, '_'); + } + }, + preprocessorOptions: { + less: { + // Inject accent/skin vars and the __DEV__ flag, matching less-loader modifyVars. + modifyVars: Object.assign({__DEV__: !isEnvProduction}, app.accent), + javascriptEnabled: true, + // Resolve `~pkg` LESS imports from node_modules (webpack/less-loader behavior). + plugins: [lessTildeImportPlugin(app.context)] + } + } + }, + build: { + outDir: path.resolve('./dist'), + emptyOutDir: true, + sourcemap: shouldUseSourceMap, + // Terser matches webpack's output quality and gzips ~6% better than + // esbuild's minifier (measured on qa-a11y: 286 KB vs 303 KB main.js + // gzip) at ~3s extra build time (Vite parallelizes Terser across + // workers). ENACT_VITE_MINIFY=esbuild opts into the faster minifier + // when build speed matters more than the last few KB. + minify: isEnvProduction ? (process.env.ENACT_VITE_MINIFY === 'esbuild' ? 'esbuild' : 'terser') : false, + cssMinify: isEnvProduction, + // Preserve webpack-style split behaviour: single main CSS when --no-split-css. + cssCodeSplit: !noSplitCSS, + commonjsOptions: { + // Enact deps (notably iLib) mix ESM/CJS and use runtime platform detection + // that require()s Node/Qt/Rhino-only loaders. Those branches never execute + // in the browser, so ignore them at bundle time instead of failing to + // resolve. (Webpack handled iLib via ILibPlugin + WebpackLoader + node + // polyfills; a proper Vite ILib plugin is the real fix — see docs.) + transformMixedEsModules: true, + ignoreDynamicRequires: true, + ignore: id => ILIB_LOADER_RE.test(id) + }, + rollupOptions: { + // Named `main` so output is `main.js`, matching the webpack bundle name. + input: {main: entry}, + output: { + entryFileNames: contentHash ? '[name].[hash].js' : '[name].js', + chunkFileNames: contentHash ? 'chunk.[name].[hash].js' : 'chunk.[name].js', + assetFileNames: contentHash ? '[name].[hash][extname]' : '[name][extname]' + } + } + }, + esbuild: { + legalComments: 'none' + }, + optimizeDeps: { + // Enact apps ship no `index.html`, so Vite's dependency scanner has no + // default entry to crawl and would otherwise discover every dependency + // lazily on the first request, each new one triggering a re-optimize + + // full page reload. That churns badly for apps that import source from + // sibling packages (e.g. `all-samples`). Point the scanner at the app + // entry so it crawls the whole import graph (including cross-package + // imports) and pre-bundles everything in one pass. + // Scan the *combined* entry, not just the app entry: it imports both the + // core-js polyfills and the app, so one crawl covers everything the + // browser will actually request. Pointing at the app entry alone left + // core-js undiscovered, which then triggered a re-optimize on first load. + entries: [path.relative(app.context, entry).replace(/\\/g, '/')], + // The dev-server dependency scanner/optimizer is esbuild-based and defaults + // the `.js` loader to `js`; Enact authors JSX inside plain `.js` files, which + // breaks the scan without this. (Request-time transforms go through + // @vitejs/plugin-react's babel, which already handles JSX-in-.js.) + esbuildOptions: { + loader: {'.js': 'jsx'}, + // Order matters: stub iLib loaders first, then babel-transform @enact source. + plugins: [ilibStubEsbuildPlugin, enactBabelEsbuildPlugin] + } + }, + server: { + host: process.env.HOST || '0.0.0.0', + port: parseInt(process.env.PORT || 8080), + hmr: true, + fs: { + // Enact apps can import source/assets from sibling package directories + // outside the app root (e.g. the aggregate `all-samples` pulls views and + // fonts from neighbouring sample packages). Vite's default fs allow-list + // (the workspace root) blocks those with "outside of Vite serving allow + // list". Disable the restriction so the dev server serves any imported + // file, matching webpack-dev-server's behaviour. + strict: false + } + }, + plugins: [ + // Rewrite webpack's `module.hot` in app source before other transforms. + enactNeutralizeWebpackHmrPlugin(), + // Webpack `resolve.modules` parity: resolve bare specifiers from the app-root + // node_modules when they can't be resolved from the importer + enactAppModulesResolverPlugin(app.context), + // `forceCSSModules`: scope ALL css/less/scss as CSS modules (not just *.module.*). + // Otherwise plain css/less/scss stays global (webpack `mode:'icss'`) and only + // needs the ICSS default export so `import css from './App.less'` resolves. + app.forceCSSModules ? enactForceCSSModulesPlugin(forcedCSSVirtual) : enactICSSInteropPlugins(), + // Node builtin polyfills for the browser (webpack: node-polyfill-webpack-plugin + // with additionalAliases console/domain/process/stream). `global` is already + // supplied by ViteHtmlPlugin's head shim (R1), so only inject Buffer/process. + // Skip for non-browser targets. Dropped for the SSR build in applySsrBuild. + !['node', 'async-node', 'webworker'].includes(app.environment) && + enactNodePolyfillResolverPlugin(), + enactDevGlobalsPlugin({ + ENACT_PACK_ISOMORPHIC: !!isomorphic, + ENACT_PACK_NO_ANIMATION: !!noAnimation + }), + !['node', 'async-node', 'webworker'].includes(app.environment) && + nodePolyfills({ + globals: {Buffer: true, process: true, global: false}, + protocolImports: true + }), + // Must come after nodePolyfills so its esbuild plugin is already in the + // config by the time this rewrites it. + !['node', 'async-node', 'webworker'].includes(app.environment) && + enactNodePolyfillOptimizerFixPlugin(), + // Wrapped with a content-keyed transform cache (see wrapReactBabelCache); + // the signature invalidates on CLI version, env, browserslist targets and + // the preset itself. + ...wrapReactBabelCache(react({ + // @enact/* packages ship raw source (JSX inside .js, ESM) rather than + // pre-compiled output, so they must be transpiled like app code. Mirror + // webpack's `exclude: /node_modules.(?!@enact)/`: process everything except + // non-@enact node_modules (plus the generated entry, unless the polyfill + // is externalized — see babelExclude above). + exclude: babelExclude, + // Reuse the exact Enact babel preset so JSX/TS/decorator handling matches webpack. + babel: { + babelrc: false, + configFile: false, + // Advertise ESM support so babel-preset-enact's @babel/preset-env + // (`modules: 'auto'`) preserves `import`/`export` for Rollup to bundle + // and tree-shake. babel-loader sets this in the webpack path; the Vite + // react plugin does not, so without it preset-env emits CommonJS and the + // app collapses into un-bundled runtime `require()` calls. + caller: { + name: 'vite-plugin-react', + supportsStaticESM: true, + supportsDynamicImport: true, + supportsTopLevelAwait: true + }, + presets: [require.resolve('babel-preset-enact')] + } + }), path.join(app.context, 'node_modules', '.cache', 'enact-vite-babel'), [ + require('../package.json').version, + process.env.NODE_ENV, + process.env.BROWSERSLIST || '', + BABEL_PRESET_ENACT_MTIME, + // Mode changes what the entry transforms to (expanded polyfills vs + // verbatim import); without this a cached expanded entry from a normal + // build would be served to an externals-polyfill build. + externalPolyfill ? 'external-polyfill' : '' + ].join('|')), + ViteHtmlPlugin({ + entry, + // Fall back to the webOS appinfo title when no app/theme title is set. + title: app.title || ViteWebOSMetaPlugin.readTitle(app.context) || '', + template: app.template || path.join(__dirname, 'html-template.ejs') + }), + // webOS metadata: emit/serve appinfo.json + referenced icon/splash assets + // and localized resources/**/appinfo.json. Skip for non-browser targets. + !['node', 'async-node', 'webworker'].includes(app.environment) && + ViteWebOSMetaPlugin({ + context: app.context, + publicPath: app.publicUrl || '/' + }), + // iLib runtime: define ILIB_* constants and make locale/resource data + // available (build: copy trees; dev: serve from source), with optional + // `-l` locale filtering. Replaces the webpack ILibPlugin. Skip for + // non-browser targets. + !['node', 'async-node', 'webworker'].includes(app.environment) && + ViteILibPlugin({ + context: app.context, + publicPath: app.publicUrl || '/', + ilibAdditionalResourcesPath, + locales + }), + // ESLint (mirrors webpack eslint-webpack-plugin); skipped with --no-linting. + !noLinting && enactEslintPlugin() + ].filter(Boolean) + }; +}; diff --git a/config/webpack.config.js b/config/webpack.config.js index 54be65f8..5016e0f2 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -38,6 +38,7 @@ const { WebOSMetaPlugin } = require('@enact/dev-utils'); const createEnvironmentHash = require('./createEnvironmentHash'); +const {getPostCssPlugins} = require('./postcss-plugins'); // This is the production and development configuration. // It is focused on developer experience, fast rebuilds, and a minimal bundle. @@ -125,122 +126,7 @@ module.exports = function ( // Necessary for external CSS imports to work // https://github.com/facebook/create-react-app/issues/2677 ident: 'postcss', - plugins: [ - useTailwind && 'tailwindcss', - // Fix and adjust for known flexbox issues - // See https://github.com/philipwalton/flexbugs - 'postcss-flexbugs-fixes', - // Transpile stage-3 CSS standards based on browserslist targets. - // See https://preset-env.cssdb.org/features for supported features. - // Includes support for targetted auto-prefixing. - [ - 'postcss-preset-env', - { - autoprefixer: { - flexbox: 'no-2009', - remove: false - }, - stage: 3, - features: {'custom-properties': false} - } - ], - // Adds PostCSS Normalize to standardize browser quirks based on - // the browserslist targets. - !useTailwind && require('postcss-normalize'), - // Resolution indepedence support - app.ri !== false && require('postcss-resolution-independence')(app.ri), - // Support importing JSON files with ~ alias - custom plugin (must run first) - { - postcssPlugin: 'postcss-import-json-tilde', - Once (root) { - // Process all @import-json rules with ~ prefix first, before other plugins - root.walkAtRules('import-json', atRule => { - let src = atRule.params.slice(1, -1); // Remove quotes - - // Only handle ~ alias paths - if (src.startsWith('~')) { - const packagePath = src.substring(1); // Remove ~ - - try { - // Use Node.js standard module resolution - // This mimics webpack's ~ alias behavior - const currentFileDir = path.dirname(atRule.source.input.file || ''); - - // Try to resolve the module using require.resolve - // This follows standard Node.js module resolution algorithm - let resolvedPath; - try { - // First try from current file's directory - resolvedPath = require.resolve(packagePath, { - paths: [currentFileDir] - }); - } catch (e) { - // Fallback to current working directory - resolvedPath = require.resolve(packagePath, { - paths: [process.cwd()] - }); - } - - // Convert to relative path for the original plugin - const relativePath = path.relative(currentFileDir, resolvedPath); - atRule.params = `"${relativePath}"`; - } catch (error) { - // If resolution fails, try manual node_modules lookup - try { - let currentDir = path.dirname( - atRule.source.input.file || process.cwd() - ); - let found = false; - - // Walk up directories to find node_modules - while (currentDir !== path.parse(currentDir).root && !found) { - const moduleDir = path.join( - currentDir, - 'node_modules', - packagePath - ); - if (fs.existsSync(moduleDir)) { - const relativePath = path.relative( - path.dirname(atRule.source.input.file || ''), - moduleDir - ); - atRule.params = `"${relativePath}"`; - found = true; - break; - } - currentDir = path.dirname(currentDir); - } - - if (!found) { - console.warn(`Could not resolve module path: ${packagePath}`); - } - } catch (fallbackError) { - console.warn( - `Failed to resolve ${packagePath}:`, - fallbackError.message - ); - } - } - } - }); - } - }, - // Support importing JSON files in CSS - original plugin (for non-~ paths) - [ - '@daltontan/postcss-import-json', - { - map: (selector, value) => { - if (typeof value === 'object' && value !== null && value.$ref) { - const tokenPath = value.$ref.split('#/')[1]; - const cssVariableName = '--' + tokenPath.replace(/\//g, '-'); - - return `var(${cssVariableName})`; - } - return value; - } - } - ] - ].filter(Boolean) + plugins: getPostCssPlugins({useTailwind}) }, sourceMap: shouldUseSourceMap } diff --git a/docs/vite-benchmarks.md b/docs/vite-benchmarks.md new file mode 100644 index 00000000..a7ac530a --- /dev/null +++ b/docs/vite-benchmarks.md @@ -0,0 +1,184 @@ +# Measured: webpack vs Vite, command by command + +Real measurements, not estimates. These numbers are from the CLI **after** the +Vite-path optimizations described in *What changed* below; the earlier figures +they replace are quoted there. + +**App:** `limestone/samples/qa-a11y` +**Machine:** PC Intel vPro / 31.5 GB RAM, Windows, Node 24 +**Heap:** both bundlers run the same `enact pack`/`enact serve` CLI with +`NODE_OPTIONS=--max-old-space-size=8192` (webpack's `--framework` build OOMs at +the default heap, so the larger heap is given to both for fairness). + +**Methodology** — metric set follows +[rstackjs/build-tools-performance](https://github.com/rstackjs/build-tools-performance), +the reference bundler benchmark (startup, build no-cache/with-cache, memory, +output size, gzipped size): + +- **No cache** — `/node_modules/.cache` (webpack's `cache:{type:'filesystem'}` + + babel-loader cache, **and** the Vite path's babel transform + eslint caches) + and `/node_modules/.vite` (Vite's dep-optimizer cache) are deleted before + the run, so neither bundler starts warm. +- **With cache** — an immediate second run. Both runs build into an empty output + dir, so the cache is the only variable. +- **Peak memory** — the build process's Windows `PeakWorkingSet64`, sampled every + 150 ms, **plus** any `esbuild` child process (Vite uses esbuild for dependency + pre-bundling). Does not cover webpack's parallel minifier workers. +- **Gzipped** — gzip(level 9). `main.js gzip` is broken out separately from the + all-JS+CSS+HTML total, because the total is dominated by `main.css` and hides + the bundling difference. +- **Bundlers are interleaved per command** (webpack then Vite, same row, back to + back) so machine drift affects both roughly equally. This run was verified + started on an idle machine and its webpack rows are internally consistent. +- Single run per cell; treat build-time differences under ~20% as noise (see + *Caveats*). + +## Build & startup time + +| Command | webpack (no cache) | Vite (no cache) | webpack (cached) | Vite (cached) | +| --- | --- | --- | --- | --- | +| `pack` (dev) | 46.6s | 48.1s | 42.3s | **35.8s** | +| `pack -p` | 60.6s | **49.8s** | 54.6s | **20.8s** | +| `pack -p --no-minify` 1 | 31.1s | 28.0s | — | — | +| `pack -p --content-hash` | 32.8s | **28.8s** | — | — | +| `pack -p -i` (isomorphic) | 44.8s | **41.8s** | — | — | +| `pack -p -i -l en-US,ko-KR` 2 | 45.7s | 56.3s | — | — | +| `pack -p --snapshot` | 49.1s | 48.4s | — | — | +| **`serve` (dev server ready)** | **26.4s** | **2.1s** | **21.4s** | **2.1s** | + +## Peak memory (RSS) + +| Command | webpack | Vite | +| --- | --- | --- | +| `pack` (dev) | 1134 MB | 2614 MB | +| `pack -p` | 1206 MB | 1709 MB | +| `pack -p -i` | 1467 MB | 2513 MB | +| `pack -p --snapshot` | 1520 MB | 3453 MB | +| **`serve`** | **1283 MB** | **123 MB** | + +## Output size + +The gzipped `main.js` is the figure that tracks bundling quality. Total on-disk +output is dominated by iLib locale JSON (~60 MB) and is not a useful comparison. +Sizes are byte-identical across every run of this benchmark (before and after +the build-time optimizations — none of them change output). + +| Command | webpack: files / total / main.js / main.js gzip | Vite: files / total / main.js / main.js gzip | +| --- | --- | --- | +| `pack` (dev) | 6779 / 70.2 MB / 5679 KB / 925 KB | 6778 / 68.3 MB / **3417 KB** / **628 KB** | +| `pack -p` | 6778 / 59.6 MB / 1108 KB / 314 KB | 6777 / 60.1 MB / **1009 KB** / **286 KB** | +| `pack -p --no-minify` 1 | 6778 / 59.7 MB / 1108 KB / 314 KB | 6777 / 61.6 MB / 2543 KB / 471 KB | +| `pack -p --content-hash` | 6778 / 59.6 MB / 1108 KB / 315 KB | 6777 / 60.1 MB / **1009 KB** / **286 KB** | +| `pack -p -i` | 6778 / 59.7 MB / 1109 KB / 315 KB | 6777 / 60.2 MB / **1009 KB** / **286 KB** | +| `pack -p -i -l en-US,ko-KR` 2 | 6782 / 59.7 MB / 1109 KB / 315 KB | **2013 / 18.7 MB** / **1009 KB** / **286 KB** | +| `pack -p --snapshot` | 6778 / 59.7 MB / 1115 KB / 317 KB | 6777 / 60.2 MB / **1014 KB** / **288 KB** | + +## What the numbers say + +- **The dev server is Vite's decisive win: ready in 2.1s vs 26.4s cold (12×), + identical warm, at 123 MB vs 1283 MB (10× less).** This is the day-to-day + feedback loop and the main reason to migrate. +- **Vite now wins the production builds too**: `pack -p` 49.8s vs 60.6s cold + (18% faster) and **20.8s vs 54.6s cached (2.6×)** — the cached case is a + developer's normal rebuild. `--content-hash`, `-i` and `--snapshot` are at + parity or better. +- **Isomorphic — previously Vite's structural worst case (1.39× slower) — is now + faster than webpack** (41.8s vs 44.8s): the client and SSR builds run + concurrently, and the SSR build no longer wastefully copies the iLib tree. +- **The one build Vite still loses is `-i -l` (multi-locale prerender)**, 56.3s + vs 45.7s: the prerenderer reloads the SSR bundle fresh per locale so iLib + re-initializes. In exchange the deployable is 69% smaller (see Note 2). +- **Vite trades memory for build speed**: ~1.4–2.3× webpack while building + (peaking at 3.5 GB for `--snapshot`, where two builds run concurrently), but a + tenth of webpack's memory to serve. +- **Vite's bundles are smaller across the board**: production `main.js` 9% + smaller (286 KB vs 314 KB gzipped), dev bundle 32% smaller. + +## What changed (optimizations behind these numbers) + +An earlier run of this benchmark had Vite *losing* most build rows (e.g. +`pack -p` 114.2s vs webpack's 135.2s cold but 95.7s vs 83.2s cached, isomorphic +1.39× slower, and a bundle 14% *larger*). Profiling the build hook-by-hook +found the causes, all in the Enact integration rather than in Vite itself: + +1. **Bundle size** — duplicated dependencies (iLib bundled twice, plus + ramda/prop-types/classnames/warning/invariant once per `@enact/*` package) + fixed via `resolve.dedupe`; and the generated entry was excluded from babel, + so `useBuiltIns: 'entry'` never trimmed core-js — **483 core-js modules + shipped instead of webpack's 77**. Together: 1263 KB → 1009 KB. +2. **iLib data copy** (~10s, 34% of the build): `ViteILibPlugin` copied the + ~70 MB / 6,750-file locale tree with a synchronous `fs.cpSync` walk. Now an + async pooled copy that skips already-current files. +3. **ESLint serialized ahead of the build** (~3–8s): the lint plugin `await`ed + in `buildStart`. It now runs concurrently and is awaited in `closeBundle` + (errors still fail the build). +4. **No babel cache**: `@vitejs/plugin-react` has no `cacheDirectory` + equivalent, so all ~380 app + raw `@enact/*` files re-transpiled every build + — also why cached builds barely improved. A content-keyed memory+disk cache + now wraps the transform (`node_modules/.cache/enact-vite-babel`). +5. **Isomorphic ran its two builds sequentially**, and the SSR build also + copied the iLib tree into the throwaway `.enact-ssr` dir. The builds now run + concurrently (`Promise.all`) and the SSR copy is dropped (its `ILIB_*` + defines are kept — prerendering reads locale data from source). + +Also measured and **rejected**: `build.reportCompressedSize: false` (no effect — +Vite already skips it at `logLevel: 'warn'`), and dev-build sourcemaps as a +suspect (26.8s with vs 27.4s without — free). Two earlier wrong conclusions +were traced to machine drift in sequential benchmarking: a phantom "24s Terser +cost" (really ~3s, interleaved) and the old serve figures, which — worse — were +measured against a dev server that reported ready but never actually rendered +(three now-fixed bugs: CJS shims served where ESM was needed, an undefined +`ENACT_PACK_ISOMORPHIC` global, and a failed dependency pre-scan). + +**Minifier choice**: Terser remains the default (matches webpack quality; +286 KB vs 303 KB gzip against esbuild's minifier) and costs only ~3s since Vite +parallelizes it. `ENACT_VITE_MINIFY=esbuild` opts into the faster minifier. + +## Notes + +**Note 1 — `--no-minify` is not comparable; the webpack flag is a silent no-op.** +Re-verified: webpack's `main.js` is byte-identical with and without it +(1108 KB, gzip 314 KB), while Vite's grows 2.5× as expected (1009 → 2543 KB). +Cause: `terser-webpack-plugin@5` normalizes constructor options into +`options.minimizer.options`, but `dev-utils/mixins/unmangled.js` writes to +`options.terserOptions` — a key v5 never reads, so `mangle` stays +`{safari10:true}`. A pre-existing webpack-path bug, unrelated to the migration. + +**Note 2 — `-l` means different things in the two bundlers.** `locales` appears +nowhere in webpack's `ILibPlugin`: there, `-l` scopes prerendering only (matching +`pack --help`: "Locales for isomorphic mode") and the full iLib tree always +ships. `ViteILibPlugin` additionally trims the emitted locale data — hence +**2013 files / 18.7 MB vs 6782 / 59.7 MB (69% smaller)**. Attractive for a TV +app, but a behavioural deviation: a webpack build made with `-l en-US,ko-KR` can +still switch to any locale at runtime, whereas the Vite build ships no data for +unlisted locales and falls back to unlocalized output. (Shared non-locale data +such as `localematch.json` is kept, so it degrades rather than crashes.) + +## Caveats, honestly + +1. **Build timings on this machine are load-sensitive.** Earlier passes of this + same benchmark, taken while background work was running, produced 2–3× + slower figures for *both* bundlers. Only compare numbers from a single run + (bundlers are interleaved per row precisely so the within-row comparison + survives drift), and treat build-time differences under ~20% as noise. + Output sizes are deterministic and safe to compare across sessions. +2. **`--snapshot` did not emit a blob**: `V8_MKSNAPSHOT` is not set in this + environment, so both bundlers build the app and skip blob generation. The row + measures build cost, not `mksnapshot` cost. +3. Peak memory covers the build process and its esbuild children, not webpack's + parallel Terser workers, so webpack's true peak is somewhat understated. + Vite's `-i`/`--snapshot` peaks reflect the two concurrent builds — the + deliberate time-for-memory trade described above. + +## Reproducing + +```bash +cd limestone/samples/qa-a11y +rm -rf node_modules/.cache node_modules/.vite dist # clears both bundlers' caches +NODE_OPTIONS=--max-old-space-size=8192 enact pack -p # webpack +NODE_OPTIONS=--max-old-space-size=8192 enact pack -p --vite # vite +NODE_OPTIONS=--max-old-space-size=8192 enact serve # webpack +NODE_OPTIONS=--max-old-space-size=8192 enact serve --vite # vite +# optional faster minifier (+17 KB gzip): +ENACT_VITE_MINIFY=esbuild NODE_OPTIONS=--max-old-space-size=8192 enact pack -p --vite +``` diff --git a/docs/vite-eject-testing.md b/docs/vite-eject-testing.md new file mode 100644 index 00000000..7300a1e6 --- /dev/null +++ b/docs/vite-eject-testing.md @@ -0,0 +1,123 @@ +# Testing `enact eject` with Vite + +This guide walks through validating the Vite support that was added to the `eject` +command. It is written as a **manual** procedure because `eject` is destructive: it +rewrites the app's `package.json`, requires a clean git working tree, copies files +into the app, and runs `npm install`. + +## What changed + +`eject` copies the CLI's `config/` (now including `vite.config.js`, `postcss-plugins.js`) +and the `commands/` (as `scripts/`, including the `--vite`-capable `pack.js`/`serve.js`). +Two eject modes exist: + +- **Default eject** (`enact eject`): keeps the Enact scripts and rewrites the app's + npm tasks from `enact ` to `node ./scripts/.js`, preserving any flags. Adding + `--vite` here **appends `--vite`** to the bundler-driven scripts (`serve`, `pack`, + `pack-p`, `watch`) so they run the Vite path; `clean`/`lint`/`test` are left untouched. + (Flags already present on a script, e.g. `enact serve --vite`, are preserved regardless.) +- **Bare eject** (`enact eject --bare`): abandons the Enact scripts and rewrites the + npm tasks to invoke the underlying tools directly. This was **webpack-only**. Adding + `--vite` makes it emit a **Vite** barebones setup instead. + +New pieces in [commands/eject.js](../commands/eject.js): + +| Piece | Purpose | +|---|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `--vite` flag | Non-bare: appends `--vite` to the `serve`/`pack` scripts. Bare: selects the Vite flavor of the barebones setup. | +| `VITE_CAPABLE_SCRIPTS` | The scripts that understand `--vite` (`serve`, `pack`). only these get the flag in a non-bare Vite eject. | +| `bareTasksVite` | Vite npm scripts: `serve → vite`, `pack → vite build --mode development`, `pack-p → vite build`, `watch → vite build --watch --mode development`. | +| `bareDepsVite` | Bare Vite deps: just `rimraf` (Vite copies `public/` automatically, so no `cpy-cli`). | +| `VITE_ROOT_CONFIG` → `vite.config.mjs` | A root config the Vite CLI auto-loads. The Enact config `config/vite.config.js` is a factory `(mode) => InlineConfig`; this adapter maps Vite's `{command, mode}` call onto it. | + +## Test A: Non-bare Vite eject + +Goal: `enact eject --vite` (no `--bare`) points the copied scripts at the Vite path, so +the app builds/serves with Vite. The app's original scripts can be plain webpack +(`enact serve`). + +1. Eject with `--vite` (answer **yes** at the confirmation prompt): + ```bash + enact eject --vite + ``` + +2. **Verify** — expect: + - A `config/` dir containing **both** `webpack.config.js` **and** `vite.config.js`, + plus `postcss-plugins.js`. + - A `scripts/` dir containing `pack.js`, `serve.js`, `vite-utils.js`. + - `package.json` scripts with `--vite` appended to the bundler scripts: + `"serve": "node ./scripts/serve.js --vite"`, + `"pack": "node ./scripts/pack.js --vite"`, + `"pack-p": "node ./scripts/pack.js --vite -p"`, + `"watch": "node ./scripts/pack.js --vite --watch"`. + `clean`/`lint`/`test` have **no** `--vite`. + +3. **Run it:** + ```bash + npm run serve # → open the URL, confirm the app renders (Vite dev server) + npm run pack-p # → confirm ./dist is produced + ``` + Open the browser console: **no errors**, the sample renders as it did pre-eject. +--- + +## Test B: Bare Vite eject (the new path) + +Goal: `--bare --vite` produces a self-contained Vite setup that runs the Vite CLI directly. + +1. Eject bare + vite (answer **yes** at the prompt): + ```bash + enact eject --bare --vite + ``` + +2. **Verify files:** + - A **`vite.config.mjs`** at the app root containing the `createRequire` adapter that + re-exports `config/vite.config.js` as `({mode}) => enactViteConfig(mode || 'production')`. + - `config/vite.config.js` and `config/postcss-plugins.js` present. + - `package.json`: + - `scripts`: `serve → "vite"`, `pack → "vite build --mode development"`, + `pack-p → "vite build"`, `watch → "vite build --watch --mode development"`, + `clean → "rimraf build dist"`, plus `lint`/`test` unchanged. + - `devDependencies` include `vite`, `@vitejs/plugin-react`, `@enact/dev-utils`, + `babel-preset-enact`, the `postcss-*` packages, and `rimraf`. + - **No** `scripts/` dir and no `enact`/`node ./scripts/...` references (this is bare). + +3. **Run it** (deps were installed by eject; if you edited `package.json` after, `npm install`): + ```bash + npm run serve # → Vite dev server; open URL, confirm render + clean console + npm run pack-p # → vite build (production); confirm ./dist with hashed assets + npm run pack # → vite build --mode development; confirm ./dist (unminified) + npm run watch # → rebuilds on change; edit a source file, confirm rebuild, Ctrl-C + npm run clean # → removes build/ and dist/ + ``` + +4. **What "pass" looks like:** + - `npm run serve` serves the app and the browser renders it with no console errors. + - `npm run pack-p` exits 0 and writes `./dist/index.html` plus hashed JS/CSS. + - `./dist/index.html` references the built assets (open it via a static server, e.g. + `npx serve dist`, and confirm it renders — `file://` won't work due to module paths). + +5. **Known-good caveats to expect (not failures):** + - The app still receives webpack-related devDeps (webpack is a CLI dependency, so the + generic dep-merge copies it). They're unused by the Vite tasks. + - `vite.config.mjs` uses the factory's **defaults** for locale filtering, content hash, + isomorphic, etc. Bare mode intentionally drops the CLI's flag plumbing; to customize, + edit `vite.config.mjs` to pass extra factory args, e.g. + `enactViteConfig(mode, false, true /* contentHash */, false, false, false, undefined, 'tv')`. + +--- + +## Test C — Regression: bare webpack eject still works + +Confirm the default `--bare` (no `--vite`) is unchanged. + +```bash +# fresh copy as above, then: +enact eject --bare +``` + +**Verify:** `package.json` scripts use webpack directly +(`pack-p → "webpack --env production --config config/webpack.config.js && cpy public dist"`), +`cpy-cli` + `rimraf` are in devDependencies, and **no** `vite.config.mjs` is written. +`npm run pack-p` produces `./dist`. + +--- diff --git a/docs/vite-isomorphic-scope.md b/docs/vite-isomorphic-scope.md new file mode 100644 index 00000000..6b0f8558 --- /dev/null +++ b/docs/vite-isomorphic-scope.md @@ -0,0 +1,177 @@ +# `--isomorphic` prerendering on the Vite path + +`enact pack -p -i --vite` server-renders the app to static HTML per target locale +(`-l`), producing per-locale variant files plus a startup script that shows the +prerendered markup immediately and hydrates on the client — matching the webpack output +closely enough that webOS treats it as a prerendered app (`appinfo.usePrerendering = true`, +per-locale `main`). + +This document describes how that path is implemented and how it maps to (and reuses) the +webpack machinery. Implementation: +[`dev-utils/mixins/vite-isomorphic.js`](../../dev-utils/mixins/vite-isomorphic.js) + +`pack.js`'s `viteIsomorphic`. + +## What webpack does (reference) + +Files: [`dev-utils/mixins/isomorphic.js`](../../dev-utils/mixins/isomorphic.js), +[`dev-utils/plugins/PrerenderPlugin/`](../../dev-utils/plugins/PrerenderPlugin/) +(`index.js`, `vdom-server-render.js`, `FileXHR.js`, `templates.js`). + +1. **Build shape** — the app is built as a **UMD library** (`output.library='App'`, + `libraryTarget='umd'`, `globalObject='this'`) whose `default` export is the app + ReactElement. `src/index.js` exports `appElement` as default and guards + `createRoot`/`hydrateRoot` behind `typeof window` + `ENACT_PACK_ISOMORPHIC`. React is + exposed on `global` (`expose-loader`) to avoid duplicate copies. +2. **Server render** (`vdom-server-render.render`, per locale): `global.XMLHttpRequest = + FileXHR` (a synchronous fake XHR that reads iLib locale data from disk so `@enact/i18n` + can load it during SSR); `global.process.env.LANG = locale`; `require()` the built UMD + chunk in Node → `chunk.default` = the element; `reactDOMServer.renderToString(...)`; + optional per-locale font CSS via `app.fontGenerator`, prepended to the markup. +3. **HTML assembly** (`PrerenderPlugin` html hooks + `templates.js`): identical renders + across locales are **deduped/aliased**; JS `