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
observableproof are still open. Test plan signed off by @tbulva in the discussion below. - Phase 1: done. MR 252286 extended
vue3_migration.ymlto global bundles. MR 256661 moved the build tooling toconfig/vue3migration/.performance_bar.vue3_migration.ymlis 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_panelbundle. MR 252442 movedduoChatGlobalStatetoee/app/assets/javascripts/ai/state.js. MR 252443 adds thevue3_migrate_duo_panelflag 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_appis not yet declared. The Jira pages moved to Vue 3 in MR 257702 undervue3_migrate_jira, and the bundle can join that flag.super_sidebarandgraphql_explorerare not started.
Problem
Historical. This section describes the state on 2026-08-27, before Phase 1 landed.
webpack_bundle_tagnow checksGitlab::Vue3Migration.rollout?(bundle)and callsGitlab::Vue3Migration.entrypoint_for(bundle, current_user:)inapp/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.jsandexternal_context_store.jsareinfected: 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.jsisinfected: true, butINFECTION_BLOCKLISTis checked before the scanner (vue3_infection_shared.js:166-167), so the build forces one copy. One copy meansobservable()creates one mirror, using real Vue 2'sVue.observable. The blocklist does not makeobservable()safe — it stopsobservable()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. atsuper_sidebaror 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 assertinginfected === falsefor 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-levelpendingScrollToSessionsflag, 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_SESSIONlistener (ee/.../vue_merge_request_widget/widgets/agent_sessions/index.vue:57),work_plan.vue's consumer side, andee/app/assets/javascripts/orbit/components/connect_section.vue(no spec). - Producer
↔️ consumer integration inee/spec/frontend/msw_integration/ai_duo_panel/, which already mounts the real panel and router but never touches the hub. Nojest.mockonee/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.jsbuilds all its mirrors from one Vue. Prove the cross-version claim before takingstate.jsoff 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 <-- newstatus: 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— addVUE3_MIGRATION_ENTRYPOINT_SUFFIX = '.vue3_migration.yml'. Document schema unchanged. -
config/helpers/vue3_migration_loader.js— second glob overentrypoints/*.vue3_migration.yml; generaliseentryNameFromFileto two cases (pages/a/b/→pages.a.b,entrypoints/<name>.→<name>). Raise on a YAML formainordefaultwith a message pointing at Phase 3 — the first mistake a reader of the docs will make. -
config/webpack.helpers.js— extract the?vue3post-processing loop into an exportedapplyVue3Migrations(entries, { defaultEntries, migrations })that takes any entry map (base-entry values are strings, not arrays; normalise inside). Keep thedefaultEntries.includes(...)skip so./mainstays clean. -
config/webpack.config.js(entry callback, ~line 247) andconfig/rspack/entries.js— run it overbaseEntryPointsbefore the spread, soperformance_bar.vue3appears alongsideperformance_bar. -
config/helpers/vite_plugin_page_entrypoints.mjs—load()andresolveId()hard-code thepages.prefix. Widen to any<name>.vue3id and add the new names toinputOptions. Keep the "a.vue3miss throws" branch and its restart-the-dev-server message. -
lib/gitlab/vue3_migration.rb— mirror the second glob and the two-caseentry_name_from_file.definitions,entrypoint_forand 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 throughentrypoint_for. Extract the missing-.vue3fallback thatwebpack_controller_bundle_tagsalready implements (lines 56-79: fall back to the base name, report once per worker per hour viaProcessMemoryCache) into a private method used by both. Vite branch: same treatment asvite_page_entrypoint_paths(app/helpers/vite_helper.rb:30-40). Path-shaped names such as'javascripts/entrypoints/lookbook/rapid_diffs'are unaffected —entrypoint_forreturns unknown names unchanged. - Specs —
spec/lib/gitlab/vue3_migration_files_spec.rb: the orphan check assumes a siblingindex.js; for the new shape assert a siblingentrypoints/<name>.js. Keep the schema and flag-exists checks for both shapes, and add a case asserting a YAML formainis rejected.spec/frontend/config/webpack_helpers_spec.js: extend the ".vue3paths all end in?vue3" assertions to base entries.spec/helpers/webpack_helper_spec.rb:webpack_bundle_tagwith the flag on, off, and with the.vue3bundle absent. - Demonstration:
performance_bar. Two files —config/feature_flags/beta/vue3_migrate_performance_bar.ymlandapp/assets/javascripts/entrypoints/performance_bar.vue3_migration.yml. Chosen because it is 7 components, rendered only when the performance bar is enabled, andapp/assets/javascripts/performance_bar/index.js:21builds its root withelandname: 'PerformanceBarRoot', so thedata-gitlab-vue3-appmarker 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 toconfig/helpers/entry_points.js; under Option 2 (line 411), state that anything reached frommain.jsmust 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:
duoChatGlobalStatenow lives inee/app/assets/javascripts/ai/state.js.super_sidebar/state.jsandbreadcrumbs_state.jsstay inINFECTION_BLOCKLISTfor the sidebar. Original plan: MoveduoChatGlobalStateout of~/super_sidebar/state.js— nothing in the sidebar uses it — and dropstate.js(andbreadcrumbs_state.js) fromINFECTION_BLOCKLISTsoobservable()can create its second mirror. Gated on the cross-versionobservabletest in Phase 0. - Split the entry, no flag. Done in MR 252293. Original plan: Add a
duo_panelkey toconfig/helpers/entry_points.js(EE-only, so it needsee_else_ceor anIS_EEguard to keep the FOSS build resolvable), emitwebpack_bundle_tag 'duo_panel'alongside the partial, removeinitDuoPanel()fromee/app/assets/javascripts/main_ee.js. A pure refactor, verifiable on its own. Watch for order changes: the panel currently initialises insidemain's run. - Roll out with a
duo_panel.vue3_migration.ymlusing 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 KBon !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 thegroup::label unset for the owners to decide.
@tbulva, @ntepluhina, @xanf: please review and sign off on these two points.
Risks
- Bundle duplication. Every
rolloutglobal entry doubles in build output. Negligible forperformance_bar; check the bundle-size report beforesuper_sidebar. super_sidebaris 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 beinfected: falseor go through~/lib/utils/observable. - Bundle-size report on !252041 (closed) shows
mainChunk +690 KB. Confirm whether that is?vue3duplication insidemain_eeor a stale baseline — the "152 new entry points" line matches the 147 pre-existingrolloutpages, 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.jsandyarn 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 printperformance_bar.vue3; confirm the same via theentry()callback inconfig/webpack.config.js. -
yarn deps:check:all— the new YAML sits underentrypoints/, covered byno-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.ymlleaves a stale entry map, and the app silently never mounts otherwise. Confirm the served script isperformance_bar.vue3and thatdocument.querySelectorAll('[data-gitlab-vue3-app="PerformanceBarRoot"]')returns a node. - After
yarn webpack, assertpublic/assets/webpack/vue3_migration.jsoncontainsperformance_bar; removing the.vue3entry must fail the build viaVue3MigrationManifestPlugin.
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, see "Global bundles"- !252286 (merged) — Phase 1, merged
- !256661 (merged) — moved the tooling into
config/vue3migration/, merged - !252666 (merged) — build-time singleton guard (Phase 0), merged
- !252293 (merged) — Phase 2 split, merged
- !252442 (merged) — moved
duoChatGlobalState, merged - !252443 (merged) — Phase 2 rollout flag, open
- !252290 (closed) — Phase 3 draft
- #624811 — Duo panel rollout issue
- !257702 (merged) — Jira pages under Vue 3; the
jira_connect_appbundle can reuse its flag - !257702 (comment 3920924630) — pointed out that the Problem section was stale