Vue 3 migration: global bundles and main

Everyone can contribute. Help move this issue forward while earning points, leveling up and collecting rewards.

Problem

The current vue3_migration.yml mechanism (Option 1) reaches only pages under pages/**/index.js. Global bundles in app/assets/javascripts/entrypoints/ (super_sidebar, performance_bar, tracker, sentry, redirect_listbox, jira_connect_app, graphql_explorer) are invisible to it. They are declared in config/helpers/entry_points.js and emitted by webpack_bundle_tag, which never calls entrypoint_for. main.js is not a bundle at all, so it cannot be targeted by either mechanism.

Proposal

A team owning a global bundle should be able to add the same two YAML files a page team adds (a feature-flag YAML and a vue3_migration.yml), and nothing else.

MR !252041 (closed) shows the only thing that works today: "Option 2", a hand-written gon.features check plus import('…?vue3') with a try/catch fallback. This works, but it 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.

Findings

Traced with the same BFS vue3_migration_stats.mjs uses, so the counts are comparable to its buckets (S<10, M<50, L<200, XL≥200). @gitlab/ui components sit in node_modules and are excluded, as they are for pages.

entry components already reached by a live rollout new surface
super_sidebar 256 206 (80%) 50
main (EE) 217 165 (76%) 52
graphql_explorer 99
jira_connect_app 22
performance_bar 7
redirect_listbox, sentry, tracker 0

Across 582 page entries: median 16, p75 54, p90 152, max 800. So main is XL, but the smallest XL, and 40 of its 52 new components are the Duo panel (ai). Take the Duo panel out and main is a 12-component migration. behaviors/index.js reaches 100 components but owns only 1 — the rest is glql and content_editor, shared with everything else.

main is not a bundle.

config/helpers/entry_points.js declares default: ['./main'], and config/webpack.helpers.js:57 prepends it to every page entry, after which the splitChunks cacheGroup named main (config/webpack.config.js:528-533) hoists it out. main_ee / main_jh are plain static imports at main.js:41-42 resolved through the ee_else_ce alias — no entry name, so a YAML can never target them. Vite is misleading: config/vite.json globs entrypoints/, so entrypoints/main.js is a real Vite entry and _vite_main.html.haml:4 names it. Webpack ships to production.

optimization.runtimeChunk: 'single' (config/webpack.config.js:521) means separate entries share one module registry, so splitting main out is viable.

Cross-runtime channels.

The Duo panel is not a leaf. Page code reaches it via duoChatGlobalState (~/super_sidebar/state.js), sendDuoChatCommand (ee/ai/utils.js, 8 importers), and the ee/ai/events/panel hub (imported from work_items ×3, boards, vue_merge_request_widget, ci/pipeline_details).

From tmp/infection_scanner.json, where infected means "this module's import subtree reaches a Vue-family specifier" (analyze.mjs:498):

  • ee/ai/events/panel.js, helpers/event_hub_factory.js and external_context_store.js are infected: false — no Vue below them, so they are never duplicated and both runtimes share one instance. The hub is already safe, but by emergent property, not by declaration.
  • super_sidebar/state.js is infected: true, but INFECTION_BLOCKLIST is checked before the scanner (vue3_infection_shared.js:166-167), so the build forces one copy. One copy means observable() creates one mirror, using real Vue 2's Vue.observable. The blocklist does not make observable() safe — it stops observable() from creating the second mirror. That is correct today only because every renderer of that state is Vue 2. It breaks when the renderer becomes Vue 3, i.e. at super_sidebar or the Duo panel.

The hybrid test harness already exists.

spec/spec_helper.rb:221 stub_all_feature_flags enables every vue3_migrate_* flag in feature specs, so a rollout page is served as <entry>.vue3 while ./main stays uninfected — Vue 3 page, Vue 2 panel, same document. spec/features/projects/jobs/user_triggers_manual_job_with_variables_spec.rb:36-52 is an existing regression test that depends on exactly this. Jest cannot help: jest.config.base.js:6-13 pins one VUE_VERSION per process, so a duplicated-module bug is structurally invisible there.

Phase 0 — Test coverage (prerequisite, independently valuable)

Plain coverage of live features. The feature specs for the four uncovered producer surfaces must land before Phase 2, because migrating the panel flips which side runs Vue 3.

  • Pin the must-stay-shared modules. spec/frontend/scripts/infection_scanner/ already runs the analyzer under vitest (yarn vitest:infection-scanner). Add a case asserting infected === false for a declared list: ai/events/panel.js, helpers/event_hub_factory.js, external_context_store.js. Highest value — today one Vue-touching import into any of them silently splits the hub, along with its module-level pendingScrollToSessions flag, and nothing fails.
  • Feature specs for the four uncovered producer surfaces: board card session badge, work-item plan, MR agent-sessions widget, pipeline header. Each drives the page and asserts the panel opened or showed the session. These run hybrid for free and are the only tests that can catch a cross-runtime break.
  • Jest gaps: the MR widget's SHOW_SESSION listener (ee/.../vue_merge_request_widget/widgets/agent_sessions/index.vue:57), work_plan.vue's consumer side, and ee/app/assets/javascripts/orbit/components/connect_section.vue (no spec).
  • Producer↔️consumer integration in ee/spec/frontend/msw_integration/ai_duo_panel/, which already mounts the real panel and router but never touches the hub. No jest.mock on ee/ai/events/panel. Same-version, so it cannot catch duplication; it makes the contract explicit so the infection-scanner assertion has something to defend.
  • Cross-version observable. spec/frontend/lib/utils/observable_spec.js builds all its mirrors from one Vue. Prove the cross-version claim before taking state.js off the blocklist — a feature spec on a shared key (breadcrumbs) is a better vehicle than Jest.

Existing coverage for reference: every external producer spec is mock-level (jest.spyOn(eventHub, '$emit')); the one real producer→consumer test goes through state, not the hub (ee/spec/frontend/ai/tanuki_bot/components/duo_chat_state_manager_spec.js:253-295, via jest.requireActual); ai_panel_router_spec.js:15 mocks the hub outright.

Phase 1 — Extend vue3_migration.yml to global bundles

Metadata as a sibling file next to the entry:

app/assets/javascripts/entrypoints/
  performance_bar.js
  performance_bar.vue3_migration.yml   <-- new
status: rollout
feature_flag: vue3_migrate_performance_bar
group: group::<owning group>

Schema, statuses, the vue3_migrate_ name rule, the <entry>.vue3 bundle, the compiled vue3_migration.json, the missing-bundle build error and the Sentry-reported runtime fallback are all reused unchanged. Only discovery and which helper resolves the name change.

  • config/helpers/vue3_migration_file_validation.js — add VUE3_MIGRATION_ENTRYPOINT_SUFFIX = '.vue3_migration.yml'. Document schema unchanged.
  • config/helpers/vue3_migration_loader.js — second glob over entrypoints/*.vue3_migration.yml; generalise entryNameFromFile to two cases (pages/a/b/pages.a.b, entrypoints/<name>.<name>). Raise on a YAML for main or default with a message pointing at Phase 3 — the first mistake a reader of the docs will make.
  • config/webpack.helpers.js — extract the ?vue3 post-processing loop into an exported applyVue3Migrations(entries, { defaultEntries, migrations }) that takes any entry map (base-entry values are strings, not arrays; normalise inside). Keep the defaultEntries.includes(...) skip so ./main stays clean.
  • config/webpack.config.js (entry callback, ~line 247) and config/rspack/entries.js — run it over baseEntryPoints before the spread, so performance_bar.vue3 appears alongside performance_bar.
  • config/helpers/vite_plugin_page_entrypoints.mjsload() and resolveId() hard-code the pages. prefix. Widen to any <name>.vue3 id and add the new names to inputOptions. Keep the "a .vue3 miss throws" branch and its restart-the-dev-server message.
  • lib/gitlab/vue3_migration.rb — mirror the second glob and the two-case entry_name_from_file. definitions, entrypoint_for and the manifest loader are already keyed on bare entry names. Update the class comment, which says "page entrypoint" throughout.
  • app/helpers/webpack_helper.rbwebpack_bundle_tag(bundle) resolves through entrypoint_for. Extract the missing-.vue3 fallback that webpack_controller_bundle_tags already implements (lines 56-79: fall back to the base name, report once per worker per hour via ProcessMemoryCache) into a private method used by both. Vite branch: same treatment as vite_page_entrypoint_paths (app/helpers/vite_helper.rb:30-40). Path-shaped names such as 'javascripts/entrypoints/lookbook/rapid_diffs' are unaffected — entrypoint_for returns unknown names unchanged.
  • Specsspec/lib/gitlab/vue3_migration_files_spec.rb: the orphan check assumes a sibling index.js; for the new shape assert a sibling entrypoints/<name>.js. Keep the schema and flag-exists checks for both shapes, and add a case asserting a YAML for main is rejected. spec/frontend/config/webpack_helpers_spec.js: extend the ".vue3 paths all end in ?vue3" assertions to base entries. spec/helpers/webpack_helper_spec.rb: webpack_bundle_tag with the flag on, off, and with the .vue3 bundle absent.
  • Demonstration: performance_bar. Two files — config/feature_flags/beta/vue3_migrate_performance_bar.yml and app/assets/javascripts/entrypoints/performance_bar.vue3_migration.yml. Chosen because it is 7 components, rendered only when the performance bar is enabled, and app/assets/javascripts/performance_bar/index.js:21 builds its root with el and name: 'PerformanceBarRoot', so the data-gitlab-vue3-app marker is stamped (roots built with $mount() carry none). Create the migration and rollout issues first — the flag YAML needs their URLs.
  • Docsdoc/development/fe_guide/vue3_migration.md: a "Global bundles" subsection under Option 1 (line 334) with the sibling-file name and a pointer to config/helpers/entry_points.js; under Option 2 (line 411), state that anything reached from main.js must use Option 2 until Phase 3 lands.

config/plugins/vue3_migration_manifest_plugin.js needs no change — it keys off compilation.entrypoints, so the manifest and the missing-.vue3 build error cover the new entries for free.

Phase 2 — Duo panel to its own entrypoint

Removes 74 components from main's graph and makes the two rollouts independent. initDuoPanel is already entrypoint-shaped: DOM-gated on #duo-chat-panel, mounted with el and name: 'DuoPanel', markup from app/views/layouts/_page.html.haml:69dispensable_render_if_exists 'layouts/duo_chat_panel'.

  • Un-blocklist the shared state. Move duoChatGlobalState out of ~/super_sidebar/state.js — nothing in the sidebar uses it — and drop state.js (and breadcrumbs_state.js) from INFECTION_BLOCKLIST so observable() can create its second mirror. Gated on the cross-version observable test in Phase 0.
  • Split the entry, no flag. Add a duo_panel key to config/helpers/entry_points.js (EE-only, so it needs ee_else_ce or an IS_EE guard to keep the FOSS build resolvable), emit webpack_bundle_tag 'duo_panel' alongside the partial, remove initDuoPanel() from ee/app/assets/javascripts/main_ee.js. A pure refactor, verifiable on its own. Watch for order changes: the panel currently initialises inside main's run.
  • Roll out with a duo_panel.vue3_migration.yml using Phase 1. Supersedes MR !252041 (closed).

Phase 3 — main as a standalone entry

After Phase 2, main's new surface is 12 components.

config/helpers/entry_points.js
  default: ['./main'],   # kept, fallback only
  main:    './main',     # new standalone entry

config/webpack.helpers.js
  generateEntries([])    # page entries no longer carry ./main

config/webpack.config.js
  common: () => ({ name: 'common', ... })   # was 'main' — collides with the new entry

app/views/layouts/_head.html.haml
  = webpack_bundle_tag 'main'
  = webpack_controller_bundle_tags(@js_action_name)

main then becomes an ordinary global bundle that Phase 1 already covers, and webpack matches what Vite already does. It is also the only route that eventually removes Vue 2 from the page — the alternative (?vue3 inside main.js) leaves main.js and ~/commons on Vue 2 permanently, so both runtimes ship on every page for good.

This phase changes the chunk graph of every page. See Open questions.

Open questions

Two items need sign-off from the Vue 3 transition owners before work proceeds:

  • Phase 3 chunk-graph risk. Phase 3 changes the chunk graph of every page. It needs its own bundle-size comparison and a script-order check before it is committed to.
  • Group ownership. Which group:: label should own this work? This issue leaves the group:: label unset for the owners to decide.

@tbulva, @ntepluhina, @xanf: please review and sign off on these two points.

Risks

  • Bundle duplication. Every rollout global entry doubles in build output. Negligible for performance_bar; check the bundle-size report before super_sidebar.
  • super_sidebar is not a quick follow-up. 256 components, 50 new, plus the blocklist work in Phase 2 and an audit of the breadcrumb and portal DOM that pages inject into sidebar-owned nodes.
  • Three Vue graphs on one page. A rollout page already runs Vue 2 (main) and @vue/compat (the page). A global bundle adds a third. Anything shared across that boundary must be infected: false or go through ~/lib/utils/observable.
  • Bundle-size report on !252041 (closed) shows mainChunk +690 KB. Confirm whether that is ?vue3 duplication inside main_ee or a stale baseline — the "152 new entry points" line matches the 147 pre-existing rollout pages, which suggests the baseline.

Verification

  • bundle exec rspec spec/lib/gitlab/vue3_migration_files_spec.rb spec/lib/gitlab/vue3_migration_spec.rb spec/helpers/webpack_helper_spec.rb spec/helpers/vite_helper_spec.rb
  • yarn jest spec/frontend/config/webpack_helpers_spec.js and yarn vitest:infection-scanner
  • Check the entry map for both bundlers: node -e "console.log(Object.keys(require('./config/rspack/entries').entries).filter(k=>k.endsWith('.vue3')&&!k.startsWith('pages')))" must print performance_bar.vue3; confirm the same via the entry() callback in config/webpack.config.js.
  • yarn deps:check:all — the new YAML sits under entrypoints/, covered by no-imports-from-entrypoints.
  • GDK, flag off: open a page with ?performance_bar=flamegraph; the bar renders and no [data-gitlab-vue3-app] node exists for it.
  • GDK, flag on: restart the Vite dev server first — a new vue3_migration.yml leaves a stale entry map, and the app silently never mounts otherwise. Confirm the served script is performance_bar.vue3 and that document.querySelectorAll('[data-gitlab-vue3-app="PerformanceBarRoot"]') returns a node.
  • After yarn webpack, assert public/assets/webpack/vue3_migration.json contains performance_bar; removing the .vue3 entry must fail the build via Vue3MigrationManifestPlugin.

References

  • !252041 (closed) — the prototype that motivated this; Phase 2 supersedes it
  • &6252 — parent epic, Migration from Vue 2 to Vue 3
  • doc/development/fe_guide/vue3_migration.md — the mechanism being extended
Edited by 🤖 GitLab Bot 🤖