Vue 3 migration: global bundles and main

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

Status (2026-09-30)

  • Phase 0: partly done. The build-time singleton guard landed in MR 252666. The four hybrid feature specs and the cross-version observable proof are still open. Test plan signed off by @tbulva in the discussion below.
  • Phase 1: done. MR 252286 extended vue3_migration.yml to global bundles. MR 256661 moved the build tooling to config/vue3migration/. performance_bar.vue3_migration.yml is the live example. The docs have a "Global bundles" subsection.
  • Phase 2: split done, rollout pending. MR 252293 gave the Duo panel its own duo_panel bundle. MR 252442 moved duoChatGlobalState to ee/app/assets/javascripts/ai/state.js. MR 252443 adds the vue3_migrate_duo_panel flag and is open. Owned by groupduo chat.
  • Phase 3: gated. MR 252290 is a draft. It needs a bundle-size comparison and a script-order check first.
  • Other global bundles: jira_connect_app is not yet declared. The Jira pages moved to Vue 3 in MR 257702 under vue3_migrate_jira, and the bundle can join that flag. super_sidebar and graphql_explorer are not started.

Problem

Historical. This section describes the state on 2026-08-27, before Phase 1 landed. webpack_bundle_tag now checks Gitlab::Vue3Migration.rollout?(bundle) and calls Gitlab::Vue3Migration.entrypoint_for(bundle, current_user:) in app/helpers/webpack_helper.rb.

The vue3_migration.yml mechanism (Option 1) reached 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) were invisible to it. They are declared in config/helpers/entry_points.js and emitted by webpack_bundle_tag, which did not call entrypoint_for. main.js is not a bundle at all, so it still cannot be targeted by either mechanism (Phase 3).

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. Done as a build-time guard in MR 252666: the build fails when a page duplicates a module-scope singleton. The narrower draft MR 252280 was closed in its favour. Original plan: 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

Done in MR 252286. The file paths below are as they were before MR 256661 moved the loader, validation and infection helpers into config/vue3migration/.

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.mjs — load() 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.rb — webpack_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.
  • Specs — spec/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.
  • Docs — doc/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

Owner: groupduo chat, DRI @tbulva. Rollout issue: #624811.

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:69 → dispensable_render_if_exists 'layouts/duo_chat_panel'.

  • Un-blocklist the shared state. Done in MR 252442: duoChatGlobalState now lives in ee/app/assets/javascripts/ai/state.js. super_sidebar/state.js and breadcrumbs_state.js stay in INFECTION_BLOCKLIST for the sidebar. Original plan: 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. Done in MR 252293. Original plan: 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. Open in MR 252443. Supersedes the closed MR 252041.

Phase 3 — main as a standalone entry

Draft in MR 252290. Gated until Phase 2 has landed and the MR has its own bundle-size comparison and script-order check.

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

Sign-off received from @tbulva and @xanf on 2026-09-02, see the discussion below. Outcome:

  • Phase 3 chunk-graph risk: gate it as described. Do not hold Phases 0 to 2 on it.
  • Group ownership: split at the phase boundary. Phase 2 and its rollout belong to groupduo chat. Phases 1 and 3 are migration tooling and follow the Vue 3 tooling owners. @ntepluhina and @xanf still need to name that group.
  • Before the Phase 2 rollout, resolve whether the mainChunk +690 KB on !252041 (closed) was real duplication or a stale baseline.

Original text:

  • 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

The Phase 1 items below were verified in MR 252286. The same checks apply to each new global bundle.

  • 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

Edited by 🤖 GitLab Bot 🤖