diff --git a/app/assets/javascripts/entrypoints/main.js b/app/assets/javascripts/entrypoints/main.js index 003a2f31829aa974b4f1d5c0cbafcca91643b6ec..b627bb195a93945c93424ad40f10cfe15389b9d7 100644 --- a/app/assets/javascripts/entrypoints/main.js +++ b/app/assets/javascripts/entrypoints/main.js @@ -1,6 +1 @@ import '../main'; -import { runModules } from '~/run_modules'; - -const modules = import.meta.glob('../pages/**/index.js'); - -runModules(modules, 'CE'); diff --git a/app/assets/javascripts/entrypoints/main_ee.js b/app/assets/javascripts/entrypoints/main_ee.js deleted file mode 100644 index 5193bdd95c45e6cf7d7a007460df523badb0c811..0000000000000000000000000000000000000000 --- a/app/assets/javascripts/entrypoints/main_ee.js +++ /dev/null @@ -1,5 +0,0 @@ -import { runModules } from '~/run_modules'; - -const modules = import.meta.glob('../../../../ee/app/assets/javascripts/pages/**/index.js'); - -runModules(modules, 'EE'); diff --git a/app/assets/javascripts/entrypoints/main_jh.js b/app/assets/javascripts/entrypoints/main_jh.js deleted file mode 100644 index 8681ea933426ed5f10149449313b0c00fb1bc5bb..0000000000000000000000000000000000000000 --- a/app/assets/javascripts/entrypoints/main_jh.js +++ /dev/null @@ -1,5 +0,0 @@ -import { runModules } from '~/run_modules'; - -const modules = import.meta.glob('../../../../jh/app/assets/javascripts/pages/**/index.js'); - -runModules(modules, 'JH'); diff --git a/app/assets/javascripts/run_modules.js b/app/assets/javascripts/run_modules.js deleted file mode 100644 index b68180781b3803291d2578eb79d1eeec6970dd0f..0000000000000000000000000000000000000000 --- a/app/assets/javascripts/run_modules.js +++ /dev/null @@ -1,48 +0,0 @@ -const paths = []; -const allModules = {}; - -const prefixes = { - CE: '../pages/', - EE: '../../../../ee/app/assets/javascripts/pages/', - JH: '../../../../jh/app/assets/javascripts/pages/', -}; - -const editionExcludes = { - JH: [], - EE: [prefixes.JH], - CE: [prefixes.EE, prefixes.JH], -}; - -const runWithExcludes = (edition) => { - const prefix = prefixes[edition]; - const excludes = editionExcludes[edition]; - paths.forEach((path) => { - const hasDuplicateEntrypoint = excludes.some( - (editionPrefix) => `${editionPrefix}${path}` in allModules, - ); - if (!hasDuplicateEntrypoint) allModules[`${prefix}${path}`]?.(); - }); -}; - -let pathsPopulated = false; - -const populatePaths = () => { - if (pathsPopulated) return; - paths.push( - ...document - .querySelector('meta[name="controller-path"]') - .content.split('/') - .map((part, index, arr) => `${[...arr.slice(0, index), part].join('/')}/index.js`), - ); - pathsPopulated = true; -}; - -export const runModules = (modules, edition) => { - populatePaths(); - Object.assign(allModules, modules); - // wait before all modules have been collected to exclude duplicates between CE and EE\JH - // <script> runs as a macrotask, can't schedule with promises here - requestAnimationFrame(() => { - runWithExcludes(edition); - }); -}; diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index adb576d22cdcdef21d022f0cac9c345c51d48263..2336dacc928f1b545c44e6760a97baeb93bf4bf4 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -499,15 +499,6 @@ def hidden_resource_icon(resource, css_class: nil) end end - def controller_full_path - action = case controller.action_name - when 'create' then 'new' - when 'update' then 'edit' - else controller.action_name - end - "#{controller.controller_path}/#{action}" - end - private def browser_id diff --git a/app/helpers/vite_helper.rb b/app/helpers/vite_helper.rb index 556155f3c85972c3607c43d4b10d636e9fd140d1..644fc65427063079efce74859996f1aef9a9be49 100644 --- a/app/helpers/vite_helper.rb +++ b/app/helpers/vite_helper.rb @@ -15,4 +15,17 @@ def vite_hmr_websocket_url def vite_hmr_http_url ViteRuby.env['VITE_HMR_HTTP_URL'] end + + def vite_page_entrypoint_paths + action = case controller.action_name + when 'create' then 'new' + when 'update' then 'edit' + else controller.action_name + end + + parts = (controller.controller_path.split('/') << action) + + parts.map + .with_index { |part, idx| "pages.#{(parts[0, idx] << part).join('.')}.js" } + end end diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index 4be82f6339ad9223613be36f04df60b25b921703..f6c71fd6fb1994023efa323aa4c04dece5f935a0 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -55,14 +55,9 @@ = webpack_bundle_tag 'performance_bar' if performance_bar_enabled? - if vite_enabled? - %meta{ name: 'controller-path', content: controller_full_path } - if Rails.env.development? = vite_client_tag - = vite_javascript_tag "main" - - if Gitlab.ee? - = vite_javascript_tag "main_ee" - - if Gitlab.jh? - = vite_javascript_tag "main_jh" + = vite_javascript_tag "main", *vite_page_entrypoint_paths = yield :page_specific_javascripts diff --git a/config/webpack.config.js b/config/webpack.config.js index 52cca8fa6dee997a0d1bc50003670834f3193130..fda6bb7b5643ffbae80f77ff11eab0c513ea0a98 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -22,7 +22,6 @@ const BABEL_VERSION = require('@babel/core/package.json').version; const BABEL_LOADER_VERSION = require('babel-loader/package.json').version; const CompressionPlugin = require('compression-webpack-plugin'); const CopyWebpackPlugin = require('copy-webpack-plugin'); -const glob = require('glob'); // eslint-disable-next-line import/no-dynamic-require const { VueLoaderPlugin } = require(VUE_LOADER_MODULE); // eslint-disable-next-line import/no-dynamic-require @@ -45,6 +44,7 @@ const { GITLAB_WEB_IDE_PUBLIC_PATH, copyFilesPatterns, } = require('./webpack.constants'); +const { generateEntries } = require('./webpack.helpers'); const createIncrementalWebpackCompiler = require('./helpers/incremental_webpack_compiler'); const vendorDllHash = require('./helpers/vendor_dll_hash'); @@ -90,10 +90,6 @@ if (WEBPACK_REPORT) { const devtool = IS_PRODUCTION ? 'source-map' : 'cheap-module-eval-source-map'; -let autoEntriesCount = 0; -let watchAutoEntries = []; -const defaultEntries = ['./main']; - const incrementalCompiler = createIncrementalWebpackCompiler( INCREMENTAL_COMPILER_RECORD_HISTORY, INCREMENTAL_COMPILER_ENABLED, @@ -101,82 +97,6 @@ const incrementalCompiler = createIncrementalWebpackCompiler( INCREMENTAL_COMPILER_TTL, ); -function generateEntries() { - // generate automatic entry points - const autoEntries = {}; - const autoEntriesMap = {}; - const pageEntries = glob.sync('pages/**/index.js', { - cwd: path.join(ROOT_PATH, 'app/assets/javascripts'), - }); - watchAutoEntries = [path.join(ROOT_PATH, 'app/assets/javascripts/pages/')]; - - function generateAutoEntries(entryPath, prefix = '.') { - const chunkPath = entryPath.replace(/\/index\.js$/, ''); - const chunkName = chunkPath.replace(/\//g, '.'); - autoEntriesMap[chunkName] = `${prefix}/${entryPath}`; - } - - pageEntries.forEach((entryPath) => generateAutoEntries(entryPath)); - - if (IS_EE) { - const eePageEntries = glob.sync('pages/**/index.js', { - cwd: path.join(ROOT_PATH, 'ee/app/assets/javascripts'), - }); - eePageEntries.forEach((entryPath) => generateAutoEntries(entryPath, 'ee')); - watchAutoEntries.push(path.join(ROOT_PATH, 'ee/app/assets/javascripts/pages/')); - } - - if (IS_JH) { - const eePageEntries = glob.sync('pages/**/index.js', { - cwd: path.join(ROOT_PATH, 'jh/app/assets/javascripts'), - }); - eePageEntries.forEach((entryPath) => generateAutoEntries(entryPath, 'jh')); - watchAutoEntries.push(path.join(ROOT_PATH, 'jh/app/assets/javascripts/pages/')); - } - - const autoEntryKeys = Object.keys(autoEntriesMap); - autoEntriesCount = autoEntryKeys.length; - - // import ancestor entrypoints within their children - autoEntryKeys.forEach((entry) => { - const entryPaths = [autoEntriesMap[entry]]; - const segments = entry.split('.'); - while (segments.pop()) { - const ancestor = segments.join('.'); - if (autoEntryKeys.includes(ancestor)) { - entryPaths.unshift(autoEntriesMap[ancestor]); - } - } - autoEntries[entry] = defaultEntries.concat(entryPaths); - }); - - /* - If you create manual entries, ensure that these import `app/assets/javascripts/webpack.js` right at - the top of the entry in order to ensure that the public path is correctly determined for loading - assets async. See: https://webpack.js.org/configuration/output/#outputpublicpath - - Note: WebPack 5 has an 'auto' option for the public path which could allow us to remove this option - Note 2: If you are using web-workers, you might need to reset the public path, see: - https://gitlab.com/gitlab-org/gitlab/-/issues/321656 - */ - - const manualEntries = { - default: defaultEntries, - legacy_sentry: './sentry/legacy_index.js', - sentry: './sentry/index.js', - performance_bar: './performance_bar/index.js', - jira_connect_app: './jira_connect/subscriptions/index.js', - sandboxed_mermaid: './lib/mermaid.js', - redirect_listbox: './entrypoints/behaviors/redirect_listbox.js', - sandboxed_swagger: './lib/swagger.js', - super_sidebar: './entrypoints/super_sidebar.js', - tracker: './entrypoints/tracker.js', - analytics: './entrypoints/analytics.js', - }; - - return Object.assign(manualEntries, incrementalCompiler.filterEntryPoints(autoEntries)); -} - const alias = { // Map Apollo client to apollo/client/core to prevent react related imports from being loaded '@apollo/client$': '@apollo/client/core', @@ -318,12 +238,42 @@ if (USE_VUE3) { vueLoaderOptions.compiler = require.resolve('./vue3migration/compiler'); } +const entriesState = { + autoEntriesCount: 0, + watchAutoEntries: [], +}; +const defaultEntries = ['./main']; + module.exports = { mode: IS_PRODUCTION ? 'production' : 'development', context: path.join(ROOT_PATH, 'app/assets/javascripts'), - entry: generateEntries, + entry: () => { + /* + If you create manual entries, ensure that these import `app/assets/javascripts/webpack.js` right at + the top of the entry in order to ensure that the public path is correctly determined for loading + assets async. See: https://webpack.js.org/configuration/output/#outputpublicpath + + Note: WebPack 5 has an 'auto' option for the public path which could allow us to remove this option + Note 2: If you are using web-workers, you might need to reset the public path, see: + https://gitlab.com/gitlab-org/gitlab/-/issues/321656 + */ + return { + default: defaultEntries, + legacy_sentry: './sentry/legacy_index.js', + sentry: './sentry/index.js', + performance_bar: './performance_bar/index.js', + jira_connect_app: './jira_connect/subscriptions/index.js', + sandboxed_mermaid: './lib/mermaid.js', + redirect_listbox: './entrypoints/behaviors/redirect_listbox.js', + sandboxed_swagger: './lib/swagger.js', + super_sidebar: './entrypoints/super_sidebar.js', + tracker: './entrypoints/tracker.js', + analytics: './entrypoints/analytics.js', + ...incrementalCompiler.filterEntryPoints(generateEntries({ defaultEntries, entriesState })), + }; + }, output: { path: WEBPACK_OUTPUT_PATH, @@ -522,7 +472,7 @@ module.exports = { priority: 20, name: 'main', chunks: 'initial', - minChunks: autoEntriesCount * 0.9, + minChunks: entriesState.autoEntriesCount * 0.9, }), prosemirror: { priority: 17, @@ -728,14 +678,16 @@ module.exports = { if (hasMissingNodeModules) compilation.contextDependencies.add(nodeModulesPath); // watch for changes to automatic entrypoints - watchAutoEntries.forEach((watchPath) => compilation.contextDependencies.add(watchPath)); + entriesState.watchAutoEntries.forEach((watchPath) => + compilation.contextDependencies.add(watchPath), + ); // report our auto-generated bundle count if (incrementalCompiler.enabled) { - incrementalCompiler.logStatus(autoEntriesCount); + incrementalCompiler.logStatus(entriesState.autoEntriesCount); } else { console.log( - `${autoEntriesCount} entries from '/pages' automatically added to webpack output.`, + `${entriesState.autoEntriesCount} entries from '/pages' automatically added to webpack output.`, ); } diff --git a/config/webpack.helpers.js b/config/webpack.helpers.js new file mode 100644 index 0000000000000000000000000000000000000000..dce1904b29853570d165bf19f6bf03b70ed19953 --- /dev/null +++ b/config/webpack.helpers.js @@ -0,0 +1,69 @@ +const path = require('path'); +const glob = require('glob'); +const { IS_EE, IS_JH, ROOT_PATH } = require('./webpack.constants'); + +function generateEntries({ defaultEntries, entriesState } = { defaultEntries: [] }) { + // generate automatic entry points + const autoEntries = {}; + const autoEntriesMap = {}; + const pageEntries = glob.sync('pages/**/index.js', { + cwd: path.join(ROOT_PATH, 'app/assets/javascripts'), + }); + if (entriesState) { + Object.assign(entriesState, { + watchAutoEntries: [path.join(ROOT_PATH, 'app/assets/javascripts/pages/')], + }); + } + + function generateAutoEntries(entryPath, prefix = '.') { + const chunkPath = entryPath.replace(/\/index\.js$/, ''); + const chunkName = chunkPath.replace(/\//g, '.'); + autoEntriesMap[chunkName] = `${prefix}/${entryPath}`; + } + + pageEntries.forEach((entryPath) => generateAutoEntries(entryPath)); + + if (IS_EE) { + const eePageEntries = glob.sync('pages/**/index.js', { + cwd: path.join(ROOT_PATH, 'ee/app/assets/javascripts'), + }); + eePageEntries.forEach((entryPath) => generateAutoEntries(entryPath, 'ee')); + if (entriesState) { + entriesState.watchAutoEntries.push(path.join(ROOT_PATH, 'ee/app/assets/javascripts/pages/')); + } + } + + if (IS_JH) { + const eePageEntries = glob.sync('pages/**/index.js', { + cwd: path.join(ROOT_PATH, 'jh/app/assets/javascripts'), + }); + eePageEntries.forEach((entryPath) => generateAutoEntries(entryPath, 'jh')); + if (entriesState) { + entriesState.watchAutoEntries.push(path.join(ROOT_PATH, 'jh/app/assets/javascripts/pages/')); + } + } + + const autoEntryKeys = Object.keys(autoEntriesMap); + if (entriesState) { + Object.assign(entriesState, { + autoEntriesCount: autoEntryKeys.length, + }); + } + + // import ancestor entrypoints within their children + autoEntryKeys.forEach((entry) => { + const entryPaths = [autoEntriesMap[entry]]; + const segments = entry.split('.'); + while (segments.pop()) { + const ancestor = segments.join('.'); + if (autoEntryKeys.includes(ancestor)) { + entryPaths.unshift(autoEntriesMap[ancestor]); + } + } + autoEntries[entry] = defaultEntries.concat(entryPaths); + }); + + return autoEntries; +} + +module.exports = { generateEntries }; diff --git a/package.json b/package.json index 7143a2c965f5f1b03bea5ad4fcef7ebcfce38813..26af246c5dd1b3707d5306c4a1123138624f39d1 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,8 @@ "swagger:validate": "swagger-cli validate", "webpack": "NODE_OPTIONS=\"${NODE_OPTIONS:=--max-old-space-size=5120}\" webpack --config config/webpack.config.js", "webpack-vendor": "NODE_OPTIONS=\"${NODE_OPTIONS:=--max-old-space-size=5120}\" webpack --config config/webpack.vendor.config.js", - "webpack-prod": "NODE_OPTIONS=\"${NODE_OPTIONS:=--max-old-space-size=5120}\" NODE_ENV=production webpack --config config/webpack.config.js" + "webpack-prod": "NODE_OPTIONS=\"${NODE_OPTIONS:=--max-old-space-size=5120}\" NODE_ENV=production webpack --config config/webpack.config.js", + "vite-prod": "NODE_OPTIONS=\"${NODE_OPTIONS:=--max-old-space-size=8000}\" NODE_ENV=production vite build" }, "dependencies": { "@apollo/client": "^3.5.10", diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index 18d0e92372dc54a8b3b8307b2c25334ecea8c4ea..dc28ffe657e52f1771b58a410fef01658a4afda7 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -979,38 +979,4 @@ def stub_controller_method(method_name, value) end end end - - describe '#controller_full_path' do - let(:path) { 'some_path' } - let(:action) { 'show' } - - before do - allow(helper.controller).to receive(:controller_path).and_return(path) - allow(helper.controller).to receive(:action_name).and_return(action) - end - - context 'when is create action' do - let(:action) { 'create' } - - it 'transforms to "new" path' do - expect(helper.controller_full_path).to eq("#{path}/new") - end - end - - context 'when is update action' do - let(:action) { 'update' } - - it 'transforms to "edit" path' do - expect(helper.controller_full_path).to eq("#{path}/edit") - end - end - - context 'when is show action' do - let(:action) { 'show' } - - it 'passes through' do - expect(helper.controller_full_path).to eq("#{path}/#{action}") - end - end - end end diff --git a/spec/helpers/vite_helper_spec.rb b/spec/helpers/vite_helper_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..fc3f017691f22f63a936443d5cb7992170ac2348 --- /dev/null +++ b/spec/helpers/vite_helper_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ViteHelper, feature_category: :tooling do + describe '#vite_page_entrypoint_path' do + using RSpec::Parameterized::TableSyntax + + where(:path, :action, :result) do + 'some_path' | 'create' | %w[pages.some_path.js pages.some_path.new.js] + 'some_path' | 'new' | %w[pages.some_path.js pages.some_path.new.js] + 'some_path' | 'update' | %w[pages.some_path.js pages.some_path.edit.js] + 'some_path' | 'show' | %w[pages.some_path.js pages.some_path.show.js] + 'some/long' | 'path' | %w[pages.some.js pages.some.long.js pages.some.long.path.js] + end + + with_them do + before do + allow(helper.controller).to receive(:controller_path).and_return(path) + allow(helper.controller).to receive(:action_name).and_return(action) + end + + it { expect(helper.vite_page_entrypoint_paths).to eq(result) } + end + end +end diff --git a/vite.config.js b/vite.config.js index f1d3342c719081b0a06a22075121d1afa3c72de3..7a0e78ced0391bc95c82cf58ee8d671dbdb416a0 100644 --- a/vite.config.js +++ b/vite.config.js @@ -10,6 +10,7 @@ import chokidar from 'chokidar'; import globby from 'globby'; import { viteCommonjs } from '@originjs/vite-plugin-commonjs'; import webpackConfig from './config/webpack.config'; +import { generateEntries } from './config/webpack.helpers'; import { IS_EE, IS_JH, @@ -43,6 +44,15 @@ const emptyComponent = path.resolve(javascriptsPath, 'vue_shared/components/empt const [rubyPlugin, ...rest] = RubyPlugin(); +const comment = '/* this is a virtual module used by Vite, it exists only in dev mode */\n'; + +const virtualEntrypoints = Object.entries(generateEntries()).reduce((acc, [entryName, imports]) => { + const modulePath = imports[imports.length - 1]; + const importPath = modulePath.startsWith('./') ? `~/${modulePath.substring(2)}` : modulePath; + acc[`${entryName}.js`] = `${comment}/* ${modulePath} */ import '${importPath}';\n`; + return acc; +}, {}); + // We can't use regular 'resolve' which points to sourceCodeDir in vite.json // Because we need for '~' alias to resolve to app/assets/javascripts // We can't use javascripts folder in sourceCodeDir because we also need to resolve other assets @@ -163,6 +173,22 @@ function viteCopyPlugin({ patterns }) { }; } +const entrypointsDir = '/javascripts/entrypoints/'; +const pageEntrypointsPlugin = { + name: 'page-entrypoints', + load(id) { + if (!id.startsWith('pages.')) { + return undefined; + } + return virtualEntrypoints[id] ?? `/* doesn't exist */`; + }, + resolveId(source) { + const fixedSource = source.replace(entrypointsDir, ''); + if (fixedSource.startsWith('pages.')) return { id: fixedSource }; + return undefined; + }, +}; + export default defineConfig({ cacheDir: path.resolve(__dirname, 'tmp/cache/vite'), resolve: { @@ -181,6 +207,7 @@ export default defineConfig({ ], }, plugins: [ + pageEntrypointsPlugin, viteCSSCompilerPlugin({ shouldWatch: viteGDKConfig.hmr !== null }), viteTailwindCompilerPlugin({ shouldWatch: viteGDKConfig.hmr !== null }), viteCopyPlugin({ @@ -238,4 +265,12 @@ export default defineConfig({ worker: { format: 'es', }, + build: { + rollupOptions: { + input: Object.keys(virtualEntrypoints).reduce((acc, value) => { + acc[value] = value; + return acc; + }, {}), + }, + }, });