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.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.
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
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
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. Move
duoChatGlobalStateout 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. 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. 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 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
-
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