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.yml describes the index.js beside it. Every page entry is an index.js, so pages keep the bare name.
  • <name>.vue3_migration.yml describes the <name>.js beside 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, the pages/a/b/index.jspages.a.b rule, so the loader and generateEntries share it.
  • 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. entryNameFromFile looks the described module up in it.
    • A declaration for main raises an error that points at Option 2.
  • config/webpack.helpers.js
    • Extracts the ?vue3 post-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 .vue3 keys onto the shared baseEntryPoints object.
  • config/webpack.config.js and config/rspack/entries.js
    • Call applyVue3Migrations on baseEntryPoints. Each config loads the migration YAML files once and passes the map to both generateEntries and applyVue3Migrations.
  • config/helpers/vite_plugin_page_entrypoints.mjs
    • load() and resolveId() no longer hard-code the pages. prefix. Every global entry whose module path the migration changed gets a virtual module: the .vue3 sibling of a rollout bundle, and the bare key of a migrated bundle. Without the second case Vite served a migrated global bundle from disk as Vue 2 while webpack and rspack served the ?vue3 build.
    • A .vue3 miss 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.js as text to build the index. Production never derives names; it reads the compiled manifest.
    • VUE3_MIGRATION_GLOB keeps its name, so scripts/feature_flags/used-feature-flags needs no change.
    • Adds entry_file_for(file), entry_name_for(file), and rollout?(name).
    • The text index matches both name: './path' properties and baseEntryPoints.name = './path' assignments, so the IS_EE-gated duo_panel entry 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.
  • app/helpers/webpack_helper.rb
    • webpack_bundle_tag resolves the bundle through entrypoint_for before it renders, inline. This is the only behavior change in the helper.
    • Under Vite the .vue3 name is requested with its .js extension. ViteRuby appends .js only to a name without an extension, and .vue3 reads as one, so the bare name returned 404 from the dev server.
    • Only a rollout entry consults current_user. current_user raises Devise::MissingWarden outside a Warden request, and helper specs and frontend fixtures render webpack_bundle_tag without one (for example, through gl_redirect_listbox_tag).
    • webpack_controller_bundle_tags is untouched. Bundle tags do not get its runtime fallback for a missing .vue3 asset: the build-time guard in Vue3MigrationManifestPlugin already fails the build in that case.
  • config/helpers/context_aliases_shared.js
    • Adds commons/duo_ui.js and commons/gitlab_ui.js to INFECTION_BLOCKLIST. entrypoints/performance_bar.js imports ~/commons, so the Vue 3 lane ran setConfigs on the shared @gitlab/duo-ui registry a second time, which throws in development. gitlab_ui.js has the same guard; the scanner treats it as clean today, so the entry is protective. commons/vue.js stays per lane on purpose.
  • 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.js must use Option 2.

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.rb passes: 1112 examples, 0 failures. Run it alone, it uses fast_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.rb passes: 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.js passes: 26 tests. VUE_VERSION=3 gives the same result.
  • The entry map includes both bundles:
    node -e "const {entries}=require('./config/rspack/entries'); console.log(entries['performance_bar'], entries['performance_bar.vue3'])"
    prints ./entrypoints/performance_bar.js and ./entrypoints/performance_bar.js?vue3.
  • Dir.glob(Gitlab::Vue3Migration::VUE3_MIGRATION_GLOB) returns 214 files, one of them app/assets/javascripts/entrypoints/performance_bar.vue3_migration.yml. The loader lists 193 rollout entries, performance_bar among them.
  • A vue3_migration.yml dropped into app/assets/javascripts/lib/utils/ makes the loader fail with describes app/assets/javascripts/lib/utils/index.js, which is not a bundler entry. A main.vue3_migration.yml fails with the Option 2 message.
  • Vite migrated bundles: with a temporary entrypoints/tracker.vue3_migration.yml set to status: migrated, PageEntrypointsPlugin().resolveId('/javascripts/entrypoints/tracker.js') returns a virtual id and load() returns import '~/entrypoints/tracker.js?vue3'. performance_bar.js (rollout, flag off) and super_sidebar.js (no YAML) still resolve to disk.
  • A full yarn rspack-prod build exits 0. Vue3MigrationManifestPlugin fails the build if a rollout entry has no .vue3 bundle, 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.json has one entry per rollout YAML (183 when built on 2026-09-07), performance_bar maps to vue3_migrate_performance_bar, and the file is byte-identical to rolloutEntries(loadVue3Migrations()).
    • manifest.rspack.json lists both entrypoints: performance_barperformance_bar.<hash>.chunk.js, performance_bar.vue3performance_bar.vue3.<hash>.chunk.js, with the same shared chunks.
    • The .vue3 chunk contains the compat mount wrapper and the Using Vue.js 3 (with @vue/compat) notice; the Vue 2 chunk contains neither.
    • Parsing the manifest the way load_from_compiled_manifest does yields rollout?('performance_bar') == true, so entrypoint_for returns performance_bar.vue3 when 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 rejects main and any module that is not a bundler entry.
  • 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 an index.js (sentry), main, and a non-entry module.
  • spec/frontend/config/webpack_helpers_spec.js
    • 5 cases for applyVue3Migrations on the string entry shape.
  • spec/helpers/webpack_helper_spec.rb
    • 4 cases for webpack_bundle_tag: flag off, flag on, flag on under Vite (requests performance_bar.vue3.js), and a bundle with no rollout declaration, which must not call current_user.

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:

  1. Enable vue3_migrate_performance_bar and restart the dev server. A new migration YAML file leaves a stale entry map, and without a restart the entry does not exist.
  2. Load any page with the performance bar enabled (p b toggles it).
  3. Flag off: the script tag is entrypoints/performance_bar.js, no Using Vue.js 3 line, 0 console errors.
  4. 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 shows PerformanceBarRoot on 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.
  5. In the network log only commons/vue.js and entrypoints/performance_bar.js have a ?vue3 copy. commons/duo_ui.js and lib/utils/axios_utils.js load 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 where performance_bar.vue3 is served next to a Vue 2 page is not checked for duplicated module-scope singletons. The duo_ui.js duplication above was found in the browser, not by the scanner. That check needs to learn about global rollout entries.
  • commons/index.js mixes "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 ~/commons and 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-app marker misses async roots and $mount() roots. Re-stamping on root updated in lib/utils/vue3compat/vue.js and init_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 ~/commons import
Edited by Miguel Rincon

Merge request reports

Loading
Loading