Extend vue3_migration.yml to global bundles
What does this MR do and why?
The Vue 3 rollout mechanism ("Option 1") reads a vue3_migration.yml file next to a page entry under app/assets/javascripts/pages/**. It builds a .vue3 sibling bundle. Gitlab::Vue3Migration.entrypoint_for picks between the two bundles per request, based on a feature flag. 193 entries use this mechanism today.
The mechanism only looks at pages/**. It misses the bundles that load on every page, such as super_sidebar, performance_bar, and tracker. These bundles are declared by hand in config/helpers/entry_points.js and rendered with webpack_bundle_tag, which never called entrypoint_for.
Teams that own these bundles had only the hand-written ?vue3 gate ("Option 2"). That gate is per-app boilerplate. It needs a push_frontend_feature_flag edit in a Ruby helper. It is invisible to scripts/frontend/vue3_migration_stats.mjs, to the flag-name lint, and to the build-time missing-bundle guard.
This MR extends Option 1 to cover global bundles. This is Phase 1 of the plan in the tracking issue linked below.
Approach
A migration file sits beside the entry module it describes, wherever that module is. The file name says which module:
vue3_migration.ymldescribes theindex.jsbeside it. Every page entry is anindex.js, so pages keep the bare name.<name>.vue3_migration.ymldescribes the<name>.jsbeside it.
The entry name is not derived from the path. The loader indexes every bundler entry by module path (the values of config/helpers/entry_points.js and every pages/**/index.js) and looks the module up. A file that describes any other module fails the build with the module named. This covers every hand-declared bundle, including those whose entry module is not under entrypoints/ (sentry/index.js, jira_connect/subscriptions/index.js, lib/mermaid_v11.js, lib/swagger.js, entrypoints/behaviors/redirect_listbox.js).
Everything downstream stays the same: the schema, the statuses, the vue3_migrate_ flag-name rule, the .vue3 sibling bundle, the compiled vue3_migration.json manifest, and the missing-bundle build error. The manifest output for the existing rollout entries is byte-identical before and after this MR.
The MR has four commits. The first extends the mechanism to entrypoints/. The second replaces the directory rules with the entry-map lookup. The third addresses the first review round. The fourth follows the rebase onto master: config/helpers/entry_points.js now declares duo_panel conditionally (baseEntryPoints.duo_panel = … inside if (IS_EE)), and the Ruby entry index reads that shape too.
Changes, by file:
config/helpers/vue3_migration_file_validation.js- Header comment states the file-name rule. The schema and the exports do not change.
config/helpers/entry_points.js- Exports
pageEntryName, thepages/a/b/index.js→pages.a.brule, so the loader andgenerateEntriesshare it.
- Exports
config/helpers/vue3_migration_loader.js- One glob,
app/assets/javascripts/**/*vue3_migration.yml, across CE, EE, and JH. entryModules()builds the module-path → entry-name index.entryNameFromFilelooks the described module up in it.- A declaration for
mainraises an error that points at Option 2.
- One glob,
config/webpack.helpers.js- Extracts the
?vue3post-processing into an exported pure function,applyVue3Migrations(entries, { defaultEntries, migrations }). - Handles both entry shapes: page entries are arrays, global bundles are plain strings.
- Returns a new map instead of mutating the input, so repeated
entry()calls under watch mode cannot stack.vue3keys onto the sharedbaseEntryPointsobject.
- Extracts the
config/webpack.config.jsandconfig/rspack/entries.js- Call
applyVue3MigrationsonbaseEntryPoints. Each config loads the migration YAML files once and passes the map to bothgenerateEntriesandapplyVue3Migrations.
- Call
config/helpers/vite_plugin_page_entrypoints.mjsload()andresolveId()no longer hard-code thepages.prefix. Every global entry whose module path the migration changed gets a virtual module: the.vue3sibling of arolloutbundle, and the bare key of amigratedbundle. Without the second case Vite served amigratedglobal bundle from disk as Vue 2 while webpack and rspack served the?vue3build.- A
.vue3miss still throws instead of returning an empty module, unchanged from before.
lib/gitlab/vue3_migration.rb- Mirrors the glob, the file-name rule, and the entry index. In development and test it reads
config/helpers/entry_points.jsas text to build the index. Production never derives names; it reads the compiled manifest. VUE3_MIGRATION_GLOBkeeps its name, soscripts/feature_flags/used-feature-flagsneeds no change.- Adds
entry_file_for(file),entry_name_for(file), androllout?(name). - The text index matches both
name: './path'properties andbaseEntryPoints.name = './path'assignments, so theIS_EE-gatedduo_panelentry from master is indexed like the others. definitions,entrypoint_for, and the manifest loader need no change. They were already keyed on bare entry names.
- Mirrors the glob, the file-name rule, and the entry index. In development and test it reads
app/helpers/webpack_helper.rbwebpack_bundle_tagresolves the bundle throughentrypoint_forbefore it renders, inline. This is the only behavior change in the helper.- Under Vite the
.vue3name is requested with its.jsextension. ViteRuby appends.jsonly to a name without an extension, and.vue3reads as one, so the bare name returned 404 from the dev server. - Only a rollout entry consults
current_user.current_userraisesDevise::MissingWardenoutside a Warden request, and helper specs and frontend fixtures renderwebpack_bundle_tagwithout one (for example, throughgl_redirect_listbox_tag). webpack_controller_bundle_tagsis untouched. Bundle tags do not get its runtime fallback for a missing.vue3asset: the build-time guard inVue3MigrationManifestPluginalready fails the build in that case.
config/helpers/context_aliases_shared.js- Adds
commons/duo_ui.jsandcommons/gitlab_ui.jstoINFECTION_BLOCKLIST.entrypoints/performance_bar.jsimports~/commons, so the Vue 3 lane ransetConfigson the shared@gitlab/duo-uiregistry a second time, which throws in development.gitlab_ui.jshas the same guard; the scanner treats it as clean today, so the entry is protective.commons/vue.jsstays per lane on purpose.
- Adds
doc/development/fe_guide/vue3_migration.md- Adds a "Global bundles" subsection under Option 1 with the file-name rule, and a note under Option 2 that anything reached from
main.jsmust use Option 2.
- Adds a "Global bundles" subsection under Option 1 with the file-name rule, and a note under Option 2 that anything reached from
Why main is excluded
config/helpers/entry_points.js declares default: ['./main']. config/webpack.helpers.js prepends this to every page entry instead of emitting it as a bundle of its own, and the splitChunks cache group then hoists it out. There is no main asset to swap. Both the loader and the files spec reject a declaration for main and point at Option 2 instead.
A later phase may turn main into a standalone entry. If that happens, main would need no new mechanism to support Option 1.
Demonstration: performance_bar
performance_bar renders 7 Vue components, only when the performance bar is enabled, so the blast radius is small. Its root is built with el and name: 'PerformanceBarRoot' (app/assets/javascripts/performance_bar/index.js:21), so the console logs [gitlab] [V] Using Vue.js 3 (with @vue/compat) for PerformanceBarRoot when the flag is on.
The data-gitlab-vue3-app DOM marker is not stamped for this app. The root renders a defineAsyncComponent (lazy UI chunk since 2018, see 8e1a8deee39f), so at mount time the wrapper holds only a placeholder and lib/utils/vue3compat/vue.js finds no element to mark. That is a limitation of the marker for any async root, not of this MR. Use the console line or Vue devtools to verify.
Two files cover the demonstration: the feature flag definition, and app/assets/javascripts/entrypoints/performance_bar.vue3_migration.yml. The flag is default_enabled: false.
How to verify
bundle exec rspec spec/lib/gitlab/vue3_migration_files_spec.rbpasses: 1112 examples, 0 failures. Run it alone, it usesfast_spec_helper.bundle exec rspec spec/lib/gitlab/vue3_migration_spec.rb spec/helpers/webpack_helper_spec.rb spec/helpers/listbox_helper_spec.rb spec/frontend/fixtures/listbox.rbpasses: 0 failures.yarn jest spec/frontend/config/webpack_helpers_spec.js spec/frontend/config/vue3_migration_file_validation_spec.js spec/frontend/config/plugins/vue3_migration_manifest_plugin.spec.jspasses: 26 tests.VUE_VERSION=3gives the same result.- The entry map includes both bundles:
prints
node -e "const {entries}=require('./config/rspack/entries'); console.log(entries['performance_bar'], entries['performance_bar.vue3'])"./entrypoints/performance_bar.jsand./entrypoints/performance_bar.js?vue3. Dir.glob(Gitlab::Vue3Migration::VUE3_MIGRATION_GLOB)returns 214 files, one of themapp/assets/javascripts/entrypoints/performance_bar.vue3_migration.yml. The loader lists 193 rollout entries,performance_baramong them.- A
vue3_migration.ymldropped intoapp/assets/javascripts/lib/utils/makes the loader fail withdescribes app/assets/javascripts/lib/utils/index.js, which is not a bundler entry. Amain.vue3_migration.ymlfails with the Option 2 message. - Vite
migratedbundles: with a temporaryentrypoints/tracker.vue3_migration.ymlset tostatus: migrated,PageEntrypointsPlugin().resolveId('/javascripts/entrypoints/tracker.js')returns a virtual id andload()returnsimport '~/entrypoints/tracker.js?vue3'.performance_bar.js(rollout, flag off) andsuper_sidebar.js(no YAML) still resolve to disk. - A full
yarn rspack-prodbuild exits 0.Vue3MigrationManifestPluginfails the build if a rollout entry has no.vue3bundle, so a green build already checks that the new entry was emitted. Checked on the output, since production never sees the YAML files and routes through these artifacts only:public/assets/webpack/vue3_migration.jsonhas one entry per rollout YAML (183 when built on 2026-09-07),performance_barmaps tovue3_migrate_performance_bar, and the file is byte-identical torolloutEntries(loadVue3Migrations()).manifest.rspack.jsonlists both entrypoints:performance_bar→performance_bar.<hash>.chunk.js,performance_bar.vue3→performance_bar.vue3.<hash>.chunk.js, with the same shared chunks.- The
.vue3chunk contains the compat mount wrapper and theUsing Vue.js 3 (with @vue/compat)notice; the Vue 2 chunk contains neither. - Parsing the manifest the way
load_from_compiled_manifestdoes yieldsrollout?('performance_bar') == true, soentrypoint_forreturnsperformance_bar.vue3when the flag is on.
New specs:
spec/lib/gitlab/vue3_migration_files_spec.rb- One orphan check covers both file-name shapes through
entry_file_for. - Every migration file must resolve through
entry_name_for, which rejectsmainand any module that is not a bundler entry.
- One orphan check covers both file-name shapes through
spec/lib/gitlab/vue3_migration_spec.rb- Two cases for
.rollout?. - Four cases for
.entry_name_for: a page, a hand-declared bundle whose module is anindex.js(sentry),main, and a non-entry module.
- Two cases for
spec/frontend/config/webpack_helpers_spec.js- 5 cases for
applyVue3Migrationson the string entry shape.
- 5 cases for
spec/helpers/webpack_helper_spec.rb- 4 cases for
webpack_bundle_tag: flag off, flag on, flag on under Vite (requestsperformance_bar.vue3.js), and a bundle with no rollout declaration, which must not callcurrent_user.
- 4 cases for
deps:check:all was not run. The change adds no imports inside app/assets/javascripts, so the package boundary rules cannot be affected.
Browser check
Done on GDK with Vite, signed in as root, flag off and flag on:
- Enable
vue3_migrate_performance_barand restart the dev server. A new migration YAML file leaves a stale entry map, and without a restart the entry does not exist. - Load any page with the performance bar enabled (
pbtoggles it). - Flag off: the script tag is
entrypoints/performance_bar.js, noUsing Vue.js 3line, 0 console errors. - Flag on: the script tag is
entrypoints/performance_bar.vue3.js, the console logs[gitlab] [V] Using Vue.js 3 (with @vue/compat) for PerformanceBarRoot, Vue devtools showsPerformanceBarRooton 3.5.42 next to the page's own root on 2.7.16, 0 console errors, and the bar lists the page's GraphQL requests, so the axios interceptor still sees the Vue 2 lane. - In the network log only
commons/vue.jsandentrypoints/performance_bar.jshave a?vue3copy.commons/duo_ui.jsandlib/utils/axios_utils.jsload once.
?performance_bar=flamegraph is not a way to verify this: it renders the speedscope flamegraph view instead of the page.
Follow-ups before the flag is enabled anywhere
scripts/frontend/infection_scanner/infection_scanner.mjs(discoverVue3PageSeeds) seeds the always-loaded bundles as Vue 2 and iterates page entries only. A page state whereperformance_bar.vue3is served next to a Vue 2 page is not checked for duplicated module-scope singletons. Theduo_ui.jsduplication above was found in the browser, not by the scanner. That check needs to learn about global rollout entries.commons/index.jsmixes "run once" bootstrap (polyfills,bootstrap,duo_ui,axios_utils) with "run per Vue lane" bootstrap (vue.js,gitlab_ui.js). Every global bundle that imports~/commonsand migrates will hit the same question. Splitting the barrel is the structural fix; the blocklist entry is the minimal one. Removing the import from the performance bar is tracked in #627892.- The
data-gitlab-vue3-appmarker misses async roots and$mount()roots. Re-stamping on rootupdatedinlib/utils/vue3compat/vue.jsandinit_vue_app.js, or a JS registry, would make the E2E hook reliable for global bundles.
Tracked in the rollout issue below.
Open questions
The feature flag YAML uses group::foundations. There is no CODEOWNERS entry for the performance bar, and the only pre-existing performance bar flag names a group that looks stale. Reviewers: please correct the group if it is wrong.
References
- #623999 — tracking issue; this MR is Phase 1 of that plan
- #624131 — feature flag rollout issue
- !252280 (closed) — Phase 0, the infection guard
- 655a2d2e — already on master; replaced the scanner's hardcoded entry list with
baseEntryPoints, so this MR no longer touches the scanner - !252041 (closed) — the Duo panel prototype that motivated this
- &6252 — parent epic
- #627892 — follow-up: remove
import '~/commons'from the performance bar entrypoint - !247677 (merged) — why the perf bar root is a
defineAsyncComponent, which is why the DOM marker is absent - !6508 (merged) — 2018 lazy-load split of the perf bar UI, the origin of the eager
~/commonsimport