From 3e657063ac6922a2cbd2b4c60f5611e64d6ff875 Mon Sep 17 00:00:00 2001 From: daniel-stoian-lgp Date: Thu, 30 Jul 2026 09:48:15 +0300 Subject: [PATCH] added improvements for bun bundler --- commands/pack.js | 33 ++++- config/bun/build-options.js | 13 ++ config/bun/build.mjs | 11 +- config/bun/dev-server.mjs | 10 +- config/bun/framework.js | 131 ++++++++++++------- config/bun/ilib-meta.js | 76 +++++++++--- config/bun/plugins/babel-enact.js | 66 +++++++++- config/bun/plugins/eslint-enact.js | 23 ++-- config/bun/plugins/externals-enact.js | 10 +- config/bun/plugins/framework-exclusions.js | 16 ++- config/bun/plugins/index.js | 2 +- config/bun/plugins/less-enact.js | 138 ++++++++++++++++++++- config/bun/plugins/postcss-enact.js | 48 ++++--- config/bun/plugins/resolve-enact.js | 75 ++++++----- config/bun/post-build.js | 6 +- npm-shrinkwrap.json | 24 ---- 16 files changed, 502 insertions(+), 180 deletions(-) diff --git a/commands/pack.js b/commands/pack.js index 65f3cc20..92bb02b5 100755 --- a/commands/pack.js +++ b/commands/pack.js @@ -81,6 +81,37 @@ function printErrorDetails (err, handler) { if (handler) handler(); } +// Empty the output directory without paying its deletion on the critical path: +// rename it aside (instant) and delete the old tree in the background while the +// build runs. Deleting a populated dist (~6.7k iLib files) costs seconds on +// Windows. Falls back to emptyDir when rename fails (e.g. dist is locked). +function clearOutput (output) { + // Sweep orphans from runs that exited before their background delete finished. + try { + const parent = path.dirname(output); + const prefix = `${path.basename(output)}.old-`; + for (const name of fs.readdirSync(parent)) { + if (name.startsWith(prefix)) { + fs.rm(path.join(parent, name), {recursive: true, force: true}, () => {}); + } + } + } catch (_e) { + // best-effort cleanup + } + + if (!fs.existsSync(output)) { + return fs.ensureDir(output); + } + const trash = `${output}.old-${process.pid}-${Date.now()}`; + return fs + .rename(output, trash) + .then(() => { + fs.rm(trash, {recursive: true, force: true}, () => {}); + return fs.ensureDir(output); + }) + .catch(() => fs.emptyDir(output)); +} + function buildArgs (opts) { const args = ['--context', app.context]; if (opts.production) args.push('--production'); @@ -160,7 +191,7 @@ function api (opts = {}) { console.log('Creating an optimized production build...'); } - return fs.emptyDir(output).then(() => { + return clearOutput(output).then(() => { const build = spawnBunScript('build.mjs', buildArgs({...opts, output}), {cwd: app.context}); if (opts.watch) { return build; diff --git a/config/bun/build-options.js b/config/bun/build-options.js index 09e6bd1e..74dbb219 100644 --- a/config/bun/build-options.js +++ b/config/bun/build-options.js @@ -98,6 +98,19 @@ function getResolveAliases (context) { // Package may be absent (e.g. scheduler before install); skip. } } + // react-is must understand the bundled React's element symbols. Apps rarely + // depend on it directly, so context resolution often fails and the bare + // import inside prop-types would fall back to its nested react-is@16, which + // cannot recognize React 19 elements — every PropTypes.node check would + // then emit "expected a ReactNode". Fall back to the CLI's React-19-aware + // copy (this was the pre-migration behavior). + if (!aliases['react-is']) { + try { + aliases['react-is'] = path.dirname(require.resolve('react-is/package.json')); + } catch (_e) { + // leave unaliased + } + } if (fs.existsSync(path.join(context, 'node_modules', '@enact', 'i18n', 'ilib'))) { aliases.ilib = '@enact/i18n/ilib'; } else { diff --git a/config/bun/build.mjs b/config/bun/build.mjs index 9eeb3469..d86820f2 100644 --- a/config/bun/build.mjs +++ b/config/bun/build.mjs @@ -182,7 +182,7 @@ async function buildReactGlobals (options, _buildOpts) { } } -function finalizeBuild (result, buildOpts, options) { +async function finalizeBuild (result, buildOpts, options) { if (!result.success) { for (const log of result.logs) console.error(log); return null; @@ -201,11 +201,10 @@ function finalizeBuild (result, buildOpts, options) { } } - nodeRequire('./post-build.js').applyPostBuild(options.context, options.outputPath, { + await nodeRequire('./post-build.js').applyPostBuild(options.context, options.outputPath, { publicPath: options.publicPath, ilibAdditionalResourcesPath: options.ilibAdditionalResourcesPath, - customSkin: options.customSkin, - watch: buildOpts.watch + customSkin: options.customSkin }); writeIndexHtml(options.outputPath, { @@ -319,7 +318,7 @@ async function runBuild (buildOpts) { } const result = await Bun.build(buildConfig); - const info = finalizeBuild(result, buildOpts, options); + const info = await finalizeBuild(result, buildOpts, options); if (!info && !buildOpts.watch) process.exit(1); if (info) logBuildResult(buildOpts, options, info); @@ -341,7 +340,7 @@ async function runWatchLoop (buildOpts, options, buildConfig) { building = true; try { const result = await Bun.build(buildConfig); - const rebuildInfo = finalizeBuild(result, buildOpts, options); + const rebuildInfo = await finalizeBuild(result, buildOpts, options); if (rebuildInfo) { console.log('Recompiled successfully.'); logBuildResult(buildOpts, options, rebuildInfo); diff --git a/config/bun/dev-server.mjs b/config/bun/dev-server.mjs index ba04259b..cc17034a 100644 --- a/config/bun/dev-server.mjs +++ b/config/bun/dev-server.mjs @@ -94,10 +94,9 @@ await runBuild(buildOpts, entryFile, plugins, cacheDir); writeDevHtml(cacheDir, {title: buildOpts.title, publicPath: buildOpts.publicPath, customSkin: buildOpts.customSkin}); // Copy iLib locale/resources (and webOS meta) into the serve output — same as pack. // Without this, XHR to /node_modules/ilib/locale/* 404s in the browser. -nodeRequire('./post-build.js').applyPostBuild(buildOpts.context, cacheDir, { +await nodeRequire('./post-build.js').applyPostBuild(buildOpts.context, cacheDir, { ilibAdditionalResourcesPath: buildOpts.ilibAdditionalResourcesPath, - customSkin: buildOpts.customSkin, - watch: true + customSkin: buildOpts.customSkin }); const publicDir = path.join(buildOpts.context, 'public'); @@ -121,10 +120,9 @@ async function rebuildDevBundle () { try { await runBuild(buildOpts, entryFile, plugins, cacheDir); writeDevHtml(cacheDir, {title: buildOpts.title, publicPath: buildOpts.publicPath, customSkin: buildOpts.customSkin}); - nodeRequire('./post-build.js').applyPostBuild(buildOpts.context, cacheDir, { + await nodeRequire('./post-build.js').applyPostBuild(buildOpts.context, cacheDir, { ilibAdditionalResourcesPath: buildOpts.ilibAdditionalResourcesPath, - customSkin: buildOpts.customSkin, - watch: true + customSkin: buildOpts.customSkin }); console.log('Rebuilt.'); } catch (err) { diff --git a/config/bun/framework.js b/config/bun/framework.js index 8bf0b143..03c09230 100644 --- a/config/bun/framework.js +++ b/config/bun/framework.js @@ -16,45 +16,23 @@ const ROOT_PACKAGES = [ 'react/jsx-dev-runtime' ]; -const FRAMEWORK_IGNORE = [ - '**/webpack.config.js', - '**/eslint.config.js', - '**/karma.conf.js', - '**/build/**/*.*', - '**/dist/**/*.*', - '**/@enact/dev-utils/**/*.*', - '**/@enact/docs-utils/**/*.*', - '**/@enact/storybook-utils/**/*.*', - '**/@enact/ui-test-utils/**/*.*', - '**/@enact/screenshot-test-utils/**/*.*', - '**/ilib/localedata/**/*.*', - '**/node_modules/**/*.*', - '**/samples/**/*.*', - '**/tests/**/*.*', - '**/ilib-node*.js', - '**/AsyncNodeLoader.js', - '**/NodeLoader.js', - '**/RhinoLoader.js', - '**/react-dom/cjs/react-dom-server.node.*' -]; - -const ILIB_IGNORE = [ - '!node_modules', - '!locale', - '**/ilib-node*.js', - '**/AsyncNodeLoader.js', - '**/NodeLoader.js', - '**/RhinoLoader.js' -]; - function isFrameworkModuleFile (file) { return !/(^|[/\\])test[/\\]|\.test\.(js|jsx|es6)$|[-.]specs?\.(js|jsx|es6)$|\.bak(?:\.|\/|\\)/.test(file); } function getModuleId (nodeModules, file) { const absPath = path.join(nodeModules, file); - let dir = absPath; + const parent = path.dirname(absPath); + + // dir/index.js identifies dir itself — independent of package.json. The + // check must be scoped to the immediate parent only: components without a + // package.json (e.g. limestone/Chips) would otherwise ascend and claim an + // ancestor id, shadowing the package root in the framework registry. + if (/^index\.(js|jsx|es6)$/.test(path.basename(absPath)) && parent.length > nodeModules.length) { + return path.relative(nodeModules, parent).replace(/\\/g, '/'); + } + let dir = parent; while (dir.startsWith(nodeModules) && dir.length > nodeModules.length) { const pkgPath = path.join(dir, 'package.json'); if (fs.existsSync(pkgPath)) { @@ -69,9 +47,6 @@ function getModuleId (nodeModules, file) { } catch (_e) { // ignore invalid package.json } - if (/[/\\]index\.(js|jsx|es6)$/.test(absPath)) { - return path.relative(nodeModules, dir).replace(/\\/g, '/'); - } } dir = path.dirname(dir); } @@ -147,20 +122,82 @@ function getThemeLocalModules (context) { }); } +// Packages excluded from the framework bundle at the package level. +const EXCLUDED_FRAMEWORK_PACKAGES = new Set([ + 'dev-utils', + 'docs-utils', + 'storybook-utils', + 'ui-test-utils', + 'screenshot-test-utils' +]); + +// Directories never descended into when collecting framework sources. Pruning +// at descent time matters: fast-glob's ignore filters entries but still walks +// each package's real node_modules tree (~30s per enumeration). +const WALK_PRUNE_DIRS = new Set([ + 'node_modules', + 'tests', + '__tests__', + 'samples', + 'dist', + 'build', + 'coverage', + 'localedata', + 'locale', + '.git' +]); + +const WALK_SKIP_FILES = /(?:webpack|eslint)\.config\.js$|karma\.conf\.js$|ilib-node[^/\\]*\.js$|AsyncNodeLoader\.js$|NodeLoader\.js$|RhinoLoader\.js$|react-dom-server\.node/; + +function walkPackageDir (dir, relative, results) { + let entries; + try { + entries = fs.readdirSync(dir, {withFileTypes: true}); + } catch (_e) { + return; + } + for (const entry of entries) { + const rel = relative ? `${relative}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + // Nested junctions (e.g. @enact/i18n/ilib) are directories via stat; + // use lstat-based check to avoid re-entering linked trees. + if (WALK_PRUNE_DIRS.has(entry.name) || entry.isSymbolicLink()) continue; + walkPackageDir(path.join(dir, entry.name), rel, results); + } else if (/\.(js|jsx|es6)$/.test(entry.name) && !WALK_SKIP_FILES.test(rel)) { + results.push(rel); + } + } +} + +// Collect one package's sources by realpathing its root first. In dev +// workspaces @enact packages are junctions into the framework repo; globbing +// through the junction with followSymbolicLinks:false silently skips the whole +// package (enact.js then ships without the framework), while following links +// globally recurses into nested node_modules junctions and never finishes. +function listPackageFiles (baseDir, name) { + const pkgPath = path.join(baseDir, name); + let realDir; + try { + realDir = fs.realpathSync(pkgPath); + } catch (_e) { + return []; + } + const results = []; + walkPackageDir(realDir, '', results); + return results.filter(isFrameworkModuleFile).map(file => `${name}/${file}`); +} + function getFrameworkModuleRequests (context, options = {}) { const nodeModules = path.join(context, 'node_modules'); - const enactFiles = fastGlob.sync('@enact/**/*.@(js|jsx|es6)', { - cwd: nodeModules, - onlyFiles: true, - ignore: FRAMEWORK_IGNORE, - followSymbolicLinks: false - }); - const ilibFiles = fastGlob.sync('ilib/**/*.@(js|jsx|es6)', { - cwd: nodeModules, - onlyFiles: true, - ignore: ILIB_IGNORE, - followSymbolicLinks: false - }); + const enactScope = path.join(nodeModules, '@enact'); + const enactFiles = []; + if (fs.existsSync(enactScope)) { + for (const name of fs.readdirSync(enactScope)) { + if (EXCLUDED_FRAMEWORK_PACKAGES.has(name)) continue; + enactFiles.push(...listPackageFiles(nodeModules, `@enact/${name}`)); + } + } + const ilibFiles = listPackageFiles(nodeModules, 'ilib'); const modules = new Map(); for (const pkg of ROOT_PACKAGES) { diff --git a/config/bun/ilib-meta.js b/config/bun/ilib-meta.js index 6b6c9fc5..cfb1e19b 100644 --- a/config/bun/ilib-meta.js +++ b/config/bun/ilib-meta.js @@ -103,25 +103,65 @@ function readManifestFiles (manifestPath) { return data.files || []; } -function emitManifestAssets (context, output, manifestPath, cache) { +// Copy one manifest's assets with bounded-concurrency async I/O. The iLib +// locale tree is ~6.7k small JSON files; sequential copySync with per-file +// ensureDirSync/existsSync took ~30s per build on Windows — this is the +// single largest cost of a warm `enact pack`. +const COPY_CONCURRENCY = 64; + +async function statOrNull (filePath) { + try { + return await fs.promises.stat(filePath); + } catch (_e) { + return null; + } +} + +async function copyManifestFile (src, dest, cache) { + const srcStat = await statOrNull(src); + if (!srcStat) return; + if (cache) { + const destStat = await statOrNull(dest); + if (destStat && srcStat.mtimeMs <= destStat.mtimeMs) { + return; + } + } + await fs.promises.copyFile(src, dest); +} + +async function runWithConcurrency (tasks, limit) { + let next = 0; + const workers = Array.from({length: Math.min(limit, tasks.length)}, async () => { + while (next < tasks.length) { + const index = next++; + await tasks[index](); + } + }); + await Promise.all(workers); +} + +async function emitManifestAssets (context, output, manifestPath, cache) { const dir = path.dirname(manifestPath); const relManifest = transformPath(context, manifestPath); const outManifest = path.join(output, relManifest); - fs.ensureDirSync(path.dirname(outManifest)); - if (!cache || !fs.existsSync(outManifest) || fs.statSync(manifestPath).mtimeMs > fs.statSync(outManifest).mtimeMs) { - fs.copySync(manifestPath, outManifest, {dereference: true}); + const files = readManifestFiles(manifestPath); + const entries = [{src: manifestPath, dest: outManifest}]; + for (const file of files) { + const src = path.join(dir, file); + entries.push({src, dest: path.join(output, transformPath(context, src))}); } - for (const file of readManifestFiles(manifestPath)) { - const src = path.join(dir, file); - const dest = path.join(output, transformPath(context, src)); - if (!fs.existsSync(src)) continue; - fs.ensureDirSync(path.dirname(dest)); - if (!cache || !fs.existsSync(dest) || fs.statSync(src).mtimeMs > fs.statSync(dest).mtimeMs) { - fs.copySync(src, dest, {dereference: true}); - } + // Create each output directory once, not per file. + const dirs = new Set(entries.map(entry => path.dirname(entry.dest))); + for (const dirPath of dirs) { + fs.mkdirSync(dirPath, {recursive: true}); } + + await runWithConcurrency( + entries.map(entry => () => copyManifestFile(entry.src, entry.dest, cache)), + COPY_CONCURRENCY + ); } function ensureManifest (manifestPath, create) { @@ -171,7 +211,7 @@ function getIlibDefines (context, publicPath, options = {}) { return defines; } -function applyIlibResources (context, output, options = {}) { +async function applyIlibResources (context, output, options = {}) { const ilibDir = options.ilib || findIlibPath(context); if (!ilibDir) return; @@ -198,11 +238,11 @@ function applyIlibResources (context, output, options = {}) { } } - for (const manifest of manifests) { - if (fs.existsSync(manifest)) { - emitManifestAssets(context, output, manifest, cache); - } - } + await Promise.all( + manifests + .filter(manifest => fs.existsSync(manifest)) + .map(manifest => emitManifestAssets(context, output, manifest, cache)) + ); } module.exports = {getIlibDefines, applyIlibResources, findIlibPath, resolveIlibFsPath, bundleConst}; diff --git a/config/bun/plugins/babel-enact.js b/config/bun/plugins/babel-enact.js index 807d9ec1..0ea29221 100644 --- a/config/bun/plugins/babel-enact.js +++ b/config/bun/plugins/babel-enact.js @@ -1,6 +1,16 @@ +const crypto = require('crypto'); +const fs = require('fs'); const path = require('path'); const babel = require('@babel/core'); +function getPackageVersion (name) { + try { + return require(`${name}/package.json`).version; + } catch (_e) { + return '0'; + } +} + function createBabelEnactPlugin (options = {}) { const babelConfigPath = path.join(__dirname, '..', '..', 'babel.config.js'); const compact = !!options.production; @@ -10,6 +20,27 @@ function createBabelEnactPlugin (options = {}) { babelPlugins.push(require.resolve('react-refresh/babel')); } + // Persistent transform cache (parity with babel-loader's cacheDirectory). + // Content-addressed: any change to the source, mode flags, or toolchain + // versions produces a new key, so stale entries are never served. + const context = path.resolve(options.context || process.cwd()); + const cacheDir = path.join(context, 'node_modules', '.cache', 'enact-bun', 'babel'); + const cacheKeyPrefix = [ + getPackageVersion('@babel/core'), + getPackageVersion('babel-preset-enact'), + options.production ? 'prod' : 'dev', + options.fastRefresh ? 'fr' : '', + options.sourcemap ? 'sm' : '' + ].join('|'); + let cacheDirReady = false; + + const ensureCacheDir = () => { + if (!cacheDirReady) { + fs.mkdirSync(cacheDir, {recursive: true}); + cacheDirReady = true; + } + }; + return { name: 'enact-babel', setup (build) { @@ -19,6 +50,32 @@ function createBabelEnactPlugin (options = {}) { } const source = await Bun.file(args.path).text(); + + let loader = 'js'; + if (/\.tsx?$/.test(args.path)) loader = 'ts'; + if (/\.jsx$/.test(args.path)) loader = 'jsx'; + + const hash = crypto + .createHash('sha256') + .update(cacheKeyPrefix) + .update('\0') + .update(args.path) + .update('\0') + .update(source) + .digest('hex'); + const cacheFile = path.join(cacheDir, `${hash}.js`); + + try { + const cached = await fs.promises.readFile(cacheFile, 'utf8'); + return { + loader, + contents: cached, + resolveDir: path.dirname(args.path) + }; + } catch (_e) { + // cache miss + } + const result = await babel.transformAsync(source, { filename: args.path, configFile: babelConfigPath, @@ -28,9 +85,12 @@ function createBabelEnactPlugin (options = {}) { compact }); - let loader = 'js'; - if (/\.tsx?$/.test(args.path)) loader = 'ts'; - if (/\.jsx$/.test(args.path)) loader = 'jsx'; + ensureCacheDir(); + try { + await fs.promises.writeFile(cacheFile, result.code, 'utf8'); + } catch (_e) { + // caching is best-effort; never fail the build over it + } return { loader, diff --git a/config/bun/plugins/eslint-enact.js b/config/bun/plugins/eslint-enact.js index 9584ce71..fd8b6ff1 100644 --- a/config/bun/plugins/eslint-enact.js +++ b/config/bun/plugins/eslint-enact.js @@ -1,12 +1,9 @@ const path = require('path'); const {ESLint} = require('eslint'); -const LINT_GLOBS = [ - '**/*.{js,mjs,jsx,ts,tsx}', - // Framework packages are often linked under node_modules/@enact; include them - // explicitly because ESLint skips node_modules for the broad glob. - 'node_modules/@enact/**/*.{js,mjs,jsx,ts,tsx}' -]; +// App sources only — eslint-webpack-plugin excluded node_modules (including +// linked @enact packages), and linting the framework tree costs minutes. +const LINT_GLOBS = ['**/*.{js,mjs,jsx,ts,tsx}']; // Webpack's eslint plugin only linted the module graph; Bun scans the filesystem. // Skip pack outputs (dist, dist2 from pack.sh -o=, custom --output, cache, etc.). @@ -62,12 +59,22 @@ function createEslintEnactPlugin (options = {}) { return formatterPromise; }; + let lintRun = null; + return { name: 'enact-eslint', setup (build) { // One lint pass per Bun.build (including watch rebuilds), not per module. - build.onStart(async () => { - const results = await eslint.lintFiles(LINT_GLOBS); + // Kicked off at build start but awaited at build end, so linting runs + // concurrently with bundling (matches eslint-webpack-plugin timing). + build.onStart(() => { + lintRun = eslint.lintFiles(LINT_GLOBS); + }); + + build.onEnd(async () => { + if (!lintRun) return; + const results = await lintRun; + lintRun = null; const formatter = await getFormatter(); const resultText = await formatter.format(results); diff --git a/config/bun/plugins/externals-enact.js b/config/bun/plugins/externals-enact.js index d7ff14b6..bdf939b2 100644 --- a/config/bun/plugins/externals-enact.js +++ b/config/bun/plugins/externals-enact.js @@ -122,10 +122,14 @@ function createExternalsEnactPlugin (options = {}) { "\tthrow new Error('External Enact framework not loaded. Include enact.js before the app bundle.');", '}', 'const mod = req(' + JSON.stringify(String(id)) + ');', - 'module.exports = mod && mod.__esModule ? mod.default : mod;', - 'if (mod && typeof mod === \'object\') {', + // Pure named-export modules (e.g. @enact/core/util) have __esModule + // set but no default — falling back to mod itself is required, or the + // named-export copy below writes onto undefined and throws. + "const resolved = mod && mod.__esModule && mod.default !== undefined ? mod.default : mod;", + 'module.exports = resolved;', + "if (mod && typeof mod === 'object' && resolved && (typeof resolved === 'object' || typeof resolved === 'function')) {", '\tfor (const key of Object.keys(mod)) {', - "\t\tif (key !== 'default') module.exports[key] = mod[key];", + "\t\tif (key !== 'default' && !(key in resolved)) resolved[key] = mod[key];", '\t}', '}' ].join('\n'); diff --git a/config/bun/plugins/framework-exclusions.js b/config/bun/plugins/framework-exclusions.js index 315f288a..d00d2ccd 100644 --- a/config/bun/plugins/framework-exclusions.js +++ b/config/bun/plugins/framework-exclusions.js @@ -37,15 +37,25 @@ function shouldExcludeFrameworkImport (args) { return false; } -function createFrameworkExclusionsPlugin () { +function createFrameworkExclusionsPlugin (options = {}) { + const context = options.context; + return { name: 'enact-framework-exclusions', setup (build) { build.onResolve({filter: /^react-dom\/server$/}, () => { + // Must resolve from the app context: the CLI ships its own react-dom + // whose patch version can differ, and react-dom/server hard-fails at + // module scope on any version mismatch with the bundled react (#527). + const paths = context ? [context] : undefined; try { - return {path: require.resolve('react-dom/server.browser')}; + return {path: require.resolve('react-dom/server.browser', paths && {paths})}; } catch (_e) { - return undefined; + try { + return {path: require.resolve('react-dom/server.browser')}; + } catch (_e2) { + return undefined; + } } }); diff --git a/config/bun/plugins/index.js b/config/bun/plugins/index.js index 4190f980..ae2ba6de 100644 --- a/config/bun/plugins/index.js +++ b/config/bun/plugins/index.js @@ -13,7 +13,7 @@ function createEnactPlugins (options = {}) { const plugins = []; if (options.framework) { - plugins.unshift(createFrameworkExclusionsPlugin()); + plugins.unshift(createFrameworkExclusionsPlugin({context: options.context})); } const eslintPlugin = createEslintEnactPlugin(options); diff --git a/config/bun/plugins/less-enact.js b/config/bun/plugins/less-enact.js index 5338e0ff..845f90ce 100644 --- a/config/bun/plugins/less-enact.js +++ b/config/bun/plugins/less-enact.js @@ -44,7 +44,16 @@ function createLessTildePlugin (appContext) { }; } +// The upward directory walk stats the same ancestor chain for every stylesheet +// in a package — cache per directory (search paths are per-dirname, not per-file). +const lessSearchPathCache = new Map(); + function getLessSearchPaths (filePath, appContext) { + const cacheKey = `${path.dirname(filePath)}\0${appContext}`; + if (lessSearchPathCache.has(cacheKey)) { + return lessSearchPathCache.get(cacheKey); + } + const paths = new Set([ path.dirname(filePath), path.join(appContext, 'node_modules') @@ -70,7 +79,9 @@ function getLessSearchPaths (filePath, appContext) { dir = parent; } - return [...paths]; + const result = [...paths]; + lessSearchPathCache.set(cacheKey, result); + return result; } async function compileSass (source, filePath) { @@ -79,7 +90,102 @@ async function compileSass (source, filePath) { loadPaths: [path.dirname(filePath)], style: 'expanded' }); - return result.css; + const deps = (result.loadedUrls || []) + .filter(url => url.protocol === 'file:') + .map(url => { + try { + return require('url').fileURLToPath(url); + } catch (_e) { + return null; + } + }) + .filter(Boolean); + return {css: result.css, deps}; +} + +// Persistent compiled-stylesheet cache. less.render + the postcss chain cost +// hundreds of ms per sheet on the single JS thread and dominate build time. +// Entries are keyed by (mode, path, source) and validated against the +// mtime+size of every file the compiler read (@import graph), the same +// invalidation model webpack's loaders used. +function createStyleCache (appContext, options) { + const cacheDir = path.join(appContext, 'node_modules', '.cache', 'enact-bun', 'style'); + const modeKey = JSON.stringify({ + v: 1, + production: !!options.production, + accent: options.accent || null, + ri: options.ri === undefined ? null : options.ri, + forceCSSModules: !!options.forceCSSModules, + useTailwind: !!options.useTailwind + }); + let cacheDirReady = false; + + const keyFor = (filePath, source) => + crypto.createHash('sha256') + .update(modeKey) + .update('\0') + .update(filePath) + .update('\0') + .update(source) + .digest('hex'); + + const depUnchanged = dep => { + try { + const stat = fs.statSync(dep.path); + return stat.mtimeMs === dep.mtimeMs && stat.size === dep.size; + } catch (_e) { + return false; + } + }; + + // @import-json inlines JSON files at the postcss stage; those deps are not + // visible in the compiler's import list, so such sheets are never cached. + const isCacheable = source => !source.includes('import-json'); + + return { + get (filePath, source) { + if (!isCacheable(source)) { + return null; + } + try { + const entryPath = path.join(cacheDir, `${keyFor(filePath, source)}.json`); + const entry = JSON.parse(fs.readFileSync(entryPath, {encoding: 'utf8'})); + if (entry.deps.every(depUnchanged)) { + return entry; + } + } catch (_e) { + // miss or unreadable entry + } + return null; + }, + set (filePath, source, processed, depPaths) { + if (!isCacheable(source)) { + return; + } + if (!cacheDirReady) { + fs.mkdirSync(cacheDir, {recursive: true}); + cacheDirReady = true; + } + const deps = []; + for (const depPath of depPaths || []) { + try { + const stat = fs.statSync(depPath); + deps.push({path: depPath, mtimeMs: stat.mtimeMs, size: stat.size}); + } catch (_e) { + // unstatable dep — skip; absence is caught by depUnchanged + } + } + try { + fs.writeFileSync( + path.join(cacheDir, `${keyFor(filePath, source)}.json`), + JSON.stringify({css: processed.css, exports: processed.exports, deps}), + {encoding: 'utf8'} + ); + } catch (_e) { + // caching is best-effort + } + } + }; } function writeCachedCssAsset (filePath, css, cssCacheDir) { @@ -120,6 +226,7 @@ function createLessEnactPlugin (options = {}) { const appContext = options.context || process.cwd(); const lessTildePlugin = createLessTildePlugin(appContext); const cssCacheDir = getCssCacheDir(appContext); + const styleCache = createStyleCache(appContext, options); return { name: 'enact-less', @@ -130,9 +237,16 @@ function createLessEnactPlugin (options = {}) { } const source = await Bun.file(args.path).text(); - const css = await compileSass(source, args.path); const moduleMode = isModuleStylesheet(args.path, options.forceCSSModules); - const processed = await postcssPlugin.processCss(css, args.path, moduleMode); + + const cached = styleCache.get(args.path, source); + if (cached) { + return createCssLoadResult(args.path, cached, moduleMode, cssCacheDir); + } + + const sassResult = await compileSass(source, args.path); + const processed = await postcssPlugin.processCss(sassResult.css, args.path, moduleMode); + styleCache.set(args.path, source, processed, sassResult.deps); return createCssLoadResult(args.path, processed, moduleMode, cssCacheDir); }); @@ -143,6 +257,13 @@ function createLessEnactPlugin (options = {}) { } const source = await Bun.file(args.path).text(); + const moduleMode = isModuleStylesheet(args.path, options.forceCSSModules); + + const cached = styleCache.get(args.path, source); + if (cached) { + return createCssLoadResult(args.path, cached, moduleMode, cssCacheDir); + } + const lessResult = await less.render(source, { filename: args.path, paths: getLessSearchPaths(args.path, appContext), @@ -152,8 +273,8 @@ function createLessEnactPlugin (options = {}) { plugins: [lessTildePlugin] }); - const moduleMode = isModuleStylesheet(args.path, options.forceCSSModules); const processed = await postcssPlugin.processCss(lessResult.css, args.path, moduleMode); + styleCache.set(args.path, source, processed, lessResult.imports); return createCssLoadResult(args.path, processed, moduleMode, cssCacheDir); }); @@ -167,7 +288,14 @@ function createLessEnactPlugin (options = {}) { const moduleMode = /\.module\.css$/.test(args.path) || options.forceCSSModules; const cssSource = await Bun.file(args.path).text(); + + const cached = styleCache.get(args.path, cssSource); + if (cached) { + return createCssLoadResult(args.path, cached, moduleMode, cssCacheDir); + } + const cssProcessed = await postcssPlugin.processCss(cssSource, args.path, moduleMode); + styleCache.set(args.path, cssSource, cssProcessed, []); return createCssLoadResult(args.path, cssProcessed, moduleMode, cssCacheDir); }); diff --git a/config/bun/plugins/postcss-enact.js b/config/bun/plugins/postcss-enact.js index 81da5ad0..b9f7c171 100644 --- a/config/bun/plugins/postcss-enact.js +++ b/config/bun/plugins/postcss-enact.js @@ -76,29 +76,39 @@ function buildPostcssPlugins (options = {}) { } function createPostcssEnactPlugin (options = {}) { + // Built once per plugin instance: postcss-preset-env resolves browserslist + // and assembles its feature set at construction, which is far too expensive + // to repeat for every stylesheet. All base plugins are stateless visitors. + const basePlugins = buildPostcssPlugins(options); + const baseProcessor = postcss(basePlugins); + return { async processCss (source, filePath, asModule) { - let moduleExports = {}; - const plugins = [...buildPostcssPlugins(options)]; - - if (asModule) { - plugins.unshift( - postcssModules({ - generateScopedName: (name, filename) => - getLocalIdent( - {resourcePath: filename || filePath, rootContext: options.context}, - '[name]_[local]', - name - ), - getJSON: (_cssFileName, cssExports) => { - moduleExports = cssExports; - } - }) - ); + if (!asModule) { + const plainResult = await baseProcessor.process(source, {from: filePath}); + return {css: plainResult.css, exports: undefined}; } - const result = await postcss(plugins).process(source, {from: filePath}); - return {css: result.css, exports: asModule ? moduleExports : undefined}; + // Only the postcss-modules instance is per-file (its getJSON callback + // captures this file's class map); the base chain is reused. + let moduleExports = {}; + const processor = postcss([ + postcssModules({ + generateScopedName: (name, filename) => + getLocalIdent( + {resourcePath: filename || filePath, rootContext: options.context}, + '[name]_[local]', + name + ), + getJSON: (_cssFileName, cssExports) => { + moduleExports = cssExports; + } + }), + ...basePlugins + ]); + + const result = await processor.process(source, {from: filePath}); + return {css: result.css, exports: moduleExports}; } }; } diff --git a/config/bun/plugins/resolve-enact.js b/config/bun/plugins/resolve-enact.js index 7f8eafe6..a65176e3 100644 --- a/config/bun/plugins/resolve-enact.js +++ b/config/bun/plugins/resolve-enact.js @@ -70,12 +70,6 @@ function toResolveResult (filePath) { return {path: normalizeBundlerPath(absolutePath)}; } -function isNodeBuiltin (request) { - const normalized = request.replace(/^node:/, ''); - const builtins = require('module').builtinModules || []; - return builtins.includes(normalized) || builtins.includes(`node:${normalized}`); -} - function normalizeAdditionalModulePaths (paths, context) { if (!paths) return []; const list = Array.isArray(paths) ? paths : [paths]; @@ -129,46 +123,63 @@ function createResolveEnactPlugin (options = {}) { const aliases = options.aliases || {}; const additionalModulePaths = normalizeAdditionalModulePaths(options.additionalModulePaths, context); + // Hoisted out of the resolve callback — it runs for every import in the graph. + const builtins = require('module').builtinModules || []; + const builtinsSet = new Set(builtins.concat(builtins.map(name => `node:${name}`))); + // Longest key first so `react-dom` wins over `react` for `react-dom/client`. + const aliasKeys = Object.keys(aliases).sort((a, b) => b.length - a.length); + // Bare-specifier resolution is deterministic per (request, resolveDir); the + // same specifier ('react', '@enact/ui/...') repeats across hundreds of files. + const bareResolveCache = new Map(); + return { name: 'enact-resolve', setup (build) { - build.onResolve({filter: /.*/}, args => { + // Relative imports ('./x', '../x') never match this filter, so Bun's + // native resolver handles them without a JS round-trip. It performs + // the same extension/index/package.json resolution this plugin did. + build.onResolve({filter: /^[^.]/}, args => { if (isExternalUrl(args.path)) { return {external: true}; } + // App-root imports (/assets/x) must be checked before the absolute + // bail-out: on POSIX they satisfy path.isAbsolute. + const appRootPath = resolveAppRootImport(args.path, context); + if (appRootPath) { + return toResolveResult(appRootPath); + } + + // Absolute imports (generated entries, cache CSS assets) still need JS + // resolution — Bun's native resolver rejects forward-slash absolute + // specifiers on Windows. These are few, so the cost is negligible. if (path.isAbsolute(args.path)) { const absoluteImport = resolveImportPath(args.path); if (absoluteImport) { return toResolveResult(absoluteImport); } + return undefined; } - if (args.path.startsWith('.') && args.resolveDir) { - const relativeImport = resolveImportPath(path.resolve(args.resolveDir, args.path)); - if (relativeImport) { - return toResolveResult(relativeImport); - } + if (builtinsSet.has(args.path)) { + return undefined; } - const appRootPath = resolveAppRootImport(args.path, context); - if (appRootPath) { - return toResolveResult(appRootPath); + const cacheKey = `${args.path}\0${args.resolveDir || ''}`; + if (bareResolveCache.has(cacheKey)) { + return bareResolveCache.get(cacheKey); } + const result = resolveBareSpecifier(args); + bareResolveCache.set(cacheKey, result); + return result; + }); - if (!args.path.startsWith('.') && !path.isAbsolute(args.path)) { - if (isNodeBuiltin(args.path)) { - return undefined; - } - - const fromModulePaths = resolveFromModulePaths(args.path, additionalModulePaths); - if (fromModulePaths) { - return toResolveResult(fromModulePaths); - } + function resolveBareSpecifier (args) { + const fromModulePaths = resolveFromModulePaths(args.path, additionalModulePaths); + if (fromModulePaths) { + return toResolveResult(fromModulePaths); } - // Longest key first so `react-dom` wins over `react` for `react-dom/client`. - const aliasKeys = Object.keys(aliases).sort((a, b) => b.length - a.length); for (const key of aliasKeys) { const exact = args.path === key; const prefixed = args.path.startsWith(key + '/'); @@ -218,16 +229,14 @@ function createResolveEnactPlugin (options = {}) { } } - if (!args.path.startsWith('.') && !path.isAbsolute(args.path)) { - const searchPaths = getModuleSearchPaths(args, context, additionalModulePaths); - const resolved = resolveNodeModule(args.path, searchPaths); - if (resolved) { - return toResolveResult(resolved); - } + const searchPaths = getModuleSearchPaths(args, context, additionalModulePaths); + const nodeResolved = resolveNodeModule(args.path, searchPaths); + if (nodeResolved) { + return toResolveResult(nodeResolved); } return undefined; - }); + } build.onLoad({filter: /node_modules[/\\]ilib[/\\]index\.js$/}, async args => { let source = await Bun.file(args.path).text(); diff --git a/config/bun/post-build.js b/config/bun/post-build.js index 4e666547..80eff5ce 100644 --- a/config/bun/post-build.js +++ b/config/bun/post-build.js @@ -20,12 +20,12 @@ function ensureCustomizationsDir (output) { fs.ensureDirSync(path.join(output, 'customizations')); } -function applyPostBuild (context, output, options = {}) { +async function applyPostBuild (context, output, options = {}) { copyPublicFolder(context, output); - applyIlibResources(context, output, { + await applyIlibResources(context, output, { ilibAdditionalResourcesPath: options.ilibAdditionalResourcesPath, create: true, - cache: !options.watch + cache: options.cache !== false }); applyWebOSMeta(context, output, { v8SnapshotFile: options.v8SnapshotFile diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 99eaf4fe..6e890be1 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -18786,19 +18786,6 @@ "@babel/types": "^7.28.2" } }, - "node_modules/@types/eslint": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", - "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -32517,17 +32504,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jiti": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", - "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",