Move main to Vue 3 behind a feature flag
What does this MR do and why?
Every page loads main, the global bundle that starts the tooltips, popovers, keyboard shortcuts and other page-wide apps. main still runs on Vue 2, so no page can drop Vue 2 until main moves.
This MR moves main to Vue 3 behind the vue3_migrate_main feature flag. With the flag off, nothing changes.
This MR builds on two earlier changes:
- Fix: !262801 (merged) makes the modules that hold page-wide state share it between their Vue 2 and Vue 3 copies.
- Enforce: !263173 (merged) makes the build fail when a new module like that gets two copies.
This MR needs both, because with the flag on, every page runs two copies of those modules.
The problem the flag makes worse
The Vue 3 loader builds a module twice when code for both runtimes reaches it. Each copy has its own module state. This already happens on master: a page in a rollout runs its own code on Vue 3, while main stays on Vue 2. With this flag on, it happens on every page whose own flag is off, because main runs on Vue 3 and the page entry on Vue 2.
These modules hold state that must exist once per page:
| Module | State | With two copies |
|---|---|---|
tooltips/index.js, popovers/index.js |
the one app that main.js starts and page code adds to |
a tooltip added by page code is never hidden or disposed |
behaviors/shortcuts/index.js |
the one Shortcuts instance |
one click on "Keyboard shortcuts" in the help menu opens two help modals, and the shortcuts chunk loads twice |
vue_shared/plugins/global_toast.js |
the one toast root | two toast roots |
behaviors/markdown/render_iframe.js, render_sandboxed_mermaid.js |
a WeakMap of elements being rendered |
one element can render twice |
lib/utils/axios_utils.js |
interceptors on the one axios instance |
every interceptor runs twice per request |
How common the duplicates are on master today
I simulated each rollout page on master with the infection scanner's lane walk and the loader's isInfectable rule. The seeds were main.js as Vue 2 and the page index.js as Vue 3. This shows how many of the 341 rollout page entries get two copies when their flag is on:
| Module | Pages |
|---|---|
axios_utils.js |
310 |
tooltips/index.js |
114 |
global_toast.js |
78 |
render_iframe.js, render_sandboxed_mermaid.js |
76 each |
shortcuts/index.js |
26 |
popovers/index.js |
1 |
These counts come from the import graph, not from a browser.
Why the fix is in !262801 (merged)
The first version of this MR added these 7 modules to INFECTION_BLOCKLIST. A blocklisted module builds as one Vue 2 copy, so every lane shares it. But its whole import tree also builds as Vue 2. The tooltips, popovers, shortcuts help and toast would then render with Vue 2 on every page, also with the flag on, and main could never drop Vue 2.
!262801 (merged) shares the state instead, through lib/utils/page_singleton.js. Each module is still built per runtime, so it follows the Vue version of main. That MR also removes the older entries on INFECTION_BLOCKLIST for the same reason, so this MR adds no blocklist entry for page-wide state.
What this MR changes
entrypoints/main.vue3_migration.ymldeclaresstatus: rolloutwithvue3_migrate_main. With the flag on, the layout serves themain.vue3bundle instead ofmain, in Webpack, Rspack and Vite. !261548 (merged) prepared this: it mademainits own entry and renders it throughwebpack_bundle_tagin every layout.commons/vue.jsjoinsINFECTION_BLOCKLIST. This is the one entry the blocklist is right for. The module installs the global Vue 2 plugins, such asTranslate, and Vue 2 apps still need them whenmainruns on Vue 3. Without it, Vue 2 apps fail with_vm.__ is not a function. The Vue 3 lane gets the same plugins fromvue3compat/vue.js.- The infection scanner checks one more state:
mainon Vue 3 next to every page that still runs Vue 2. - A
vue3_runtimecache group keeps@vue/compatin one chunk, in Rspack and Webpack. The super sidebar lazy-loadsglobal_search_modalandshortcutsBundleon every page, and they bring the Vue 3 runtime. Without the group,main.vue3loaded a second copy of it. - The migration guide treats
mainas a global bundle with its ownvue3_migration.yml, and points to the "Module-scope singletons" section for shared state.
Bundle size of the flag
I measured what a page downloads without a click: the layout's bundles plus the lazy chunks that the super sidebar loads on every page. The numbers are gzip, from a production Rspack build, against this MR without the vue3_runtime group.
Without vue3_runtime |
With vue3_runtime |
|
|---|---|---|
| 581 pages, flag off | 0 | 0.0 KB on 579 pages, at most +3.2 KB |
| 581 pages, flag on | +121.4 KB | +73.5 KB |
| 341 rollout pages, flag off | 0 | −47.9 KB |
- I checked the first two rows in a browser on 8 pages in GDK, served from the same production builds. The browser gave the same numbers.
- The rollout pages also downloaded the runtime twice before, once for the page and once for the super sidebar, so they get smaller.
- I measured this on the version with the blocklist. With !262801 (merged), the 7 modules build per runtime, so the flag-on number can grow a little. I did not measure it again.
What I tried and did not keep
A main_shared_vue3 group would keep the Vue 3 copies of main's modules in one chunk.
- With
chunks: 'all', the flag-on cost drops to +37.7 KB, but every page pays +37.7 KB with the flag off. The super sidebar's lazy chunks share Vue 3 modules withmain.vue3, so they then load the whole group. - With
chunks: 'initial', the flag-on cost is +71.0 KB, which saves only 2.5 KB, and rollout pages lose 30 KB of the runtime saving.
core-js in Webpack
In Webpack, Babel adds core-js imports, and the Vue 3 loader gives ?vue3 copies to modules that never reach Vue. So a Vue 3 entry loads core-js twice. Rspack builds contain no core-js, so they are not affected.
- On
master, 339 of the 341 rollout pages load core-js twice, about 155 KB raw before minification. - With the flag on, every page loads core-js twice, about 163 KB raw before minification.
The fix belongs in the Vue 3 loader and also helps the rollout pages that exist today, so it is not in this MR.
Is the shared state complete?
With !263173 (merged), the scanner's singleton detector finds the state these 7 modules held, and the new scanner state for this flag passes. Before that MR, the detector did not see them. I found them, and checked for others, in three ways:
- With the 7 modules on the blocklist: 239 app modules get a Vue 2 and a Vue 3 copy when the flag is on. None has a singleton that the detector finds.
- Without them: 275 modules, including all 7. So the new scanner state does reach them.
- By pattern: I searched the 239 modules for state the detector misses: module-level
let,WeakMapornewinstances, listeners added at import time, interceptors,windowwrites and calls at import time. 16 modules match. For each one, the state is harmless:rails_ujs.jsstarts Rails UJS once, guarded bywindow._rails_loaded.- The issuable popovers guard on DOM attributes.
- Only
mainbindsinternal_eventsandCopyAsGFMto the document. ai/utils.jswrites toee/ai/state, which is not duplicated.- The rest are caches or local flags.
The pattern search missed one module: commons/vue.js. It does not hold state. It configures the global Vue 2 runtime at import time. A local feature spec found it, and so did CI. The spot checks in !261499 (comment 4004211437) list what ran.
The browser is the real check. The pipeline has two labels for that:
pipeline:run-all-e2e: the e2e jobs includeff-inversejobs that turnvue3_migrate_mainon, because the MR adds the flag file.pipeline:run-all-rspec: feature specs turn on any flag that is not persisted, so every system spec runs withmainon Vue 3.
Screenshots or screen recordings
No visual change in either flag state. Each pair below is the same page with vue3_migrate_main off and on, at the MR head fb089e26. Every page flag is off, so the page entry runs on Vue 2 in both columns. The dark tooltip comes from a probe button that the spec adds, and main renders it: on Vue 2 on the left, on Vue 3 on the right.
main off (main.bundle.js) |
main on (main.vue3.bundle.js) |
|---|---|
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
How to set up and validate locally
- With GDK on Rspack, check out this branch and run
gdk restart rspack. - Run
Feature.enable(:vue3_migrate_main). - Open any page. The page source loads
main.vue3in place ofmain. - Run
document.querySelectorAll('#gl-tooltips-app').length. It returns1after you hover a few tooltips. - In the help menu of the left sidebar, select Keyboard shortcuts. One help modal opens.
Local checks on the current version:
yarn vitest:vue3migration run: 6 files, 214 tests passed. This includes the blocklist example ininfection_spec.mjs, which runs withcommons/vue.js.yarn infection-scanner: duplicated modules check, 706 page states, none found.commons/vue.jsis the only blocklist entry.- ESLint on the changed config and scanner files, and markdownlint and Vale on the migration guide: no errors.
Feature spec check of both flag states
The flag works on every page that loads in the local test server. The flag state changes nothing in the specs' results. I ran both checks in GDK on Rspack, at the MR head fb089e26. Feature specs turn on every flag that is not stubbed, so each run stubs every vue3_migrate_* flag explicitly.
A temporary page spec, not committed. It covers 9 pages in 3 states:
main_off: all flags off. This is what users get today.main_on:vue3_migrate_mainon, every page flag off. This is the mixed case this MR is about.all_on: every flag on.
On each page, the spec does these steps:
- It checks which
mainbundle the page loads. - It hovers a tooltip probe and a popover probe, and presses
?. - It finds every Vue root and its runtime, and reads the
pageSingletonregistry. - It collects the browser console errors.
These pages pass in all 3 states: project overview, merge request (also posts a comment), pipeline, blob, group and projects dashboard. On each of them:
- With the flag off, the page loads
main.bundle.js. With the flag on, it loadsmain.vue3.bundle.js. TooltipsRoot,PopoversRootandShortcutsHelpRooteach mount exactly once. They mount on Vue 2 with the flag off and on Vue 3 with the flag on. The page's own apps stay on Vue 2 inmain_on.- One tooltip, one popover and one keyboard shortcuts modal open.
- The
pageSingletonregistry holds the same keys with the flag off and on. - The console shows no errors, apart from blocked requests to gravatar and Snowplow.
The issue, to-do and snippet pages fail the same way in every state, main_off included. The work item page gets HTTP 503 from POST /api/graphql in the test server. The existing spec/features/work_items/issues/todo_spec.rb fails the same way on this machine. The issue and snippet pages pass in all_on. The to-do page fails in all 3 states. The earlier browser check below covers the work item page and the to-do count.
15 existing feature spec files, run with main off and with main on. Every page load served the expected bundle: 179 of 179 loaded main.bundle.js with the flag off, and 180 of 180 loaded main.vue3.bundle.js with it on. The files cover:
- keyboard shortcuts and the help menu
- Copy as GFM, Mermaid, iframes and math in markdown
- merge request notes, snippet notes and GFM autocomplete
- group members and the invite modal, and the reviewers sidebar
- to-dos, active nav items and global search
main off |
main on |
|
|---|---|---|
| Examples | 180 | 180 |
| Failures | 22 | 22 |
| Failures in both runs | 19 | 19 |
| Failures in one run only | 3 (math_spec.rb:129, :203, user_posts_notes_spec.rb:68) |
3 (math_spec.rb:192, dashboard/shortcuts_spec.rb:22, :66) |
What the failures are
- The 19 failures in both runs fail in this environment whatever the flag says:
notes_on_personal_snippets_spec.rb: the Vue 2 warningInvalid prop: type check failed for prop "link"fromCodeDropdownCloneItemon the snippet page.sandboxed_mermaid_spec.rb:#app > svgnever renders in the sandbox.todos_spec.rb:460: the list does not reload in time.
math_spec.rbfails on a different example in each run. Its requests do not settle.- I reran
dashboard/shortcuts_spec.rbalone 3 times: on, off, on. It passed 2 of 2 each time.
Local checks on the version with the blocklist, before the rebase onto !262801 (merged):
bin/rspec spec/lib/gitlab/vue3_migration_files_spec.rb: 1917 examples, 0 failures.yarn rspack-prod: compiled with the same 2 size warnings as before.
Browser check
I checked this MR in GDK, with vue3_migrate_main off and on, together with !263173 (merged) and !262801 (merged). The rebase onto master after that check did not change any line of this MR. Everything below works the same with the flag off and on, and nothing is created twice.
- With the flag off, the page loads
main.bundle.js, and everything runs on Vue 2. - With the flag on, the page loads
main.vue3.bundle.js. The tooltips app, the popovers app and the shortcuts help mount on Vue 3. The page entries (MR, work item, snippet) stay on Vue 2, which is the mixed case this stack is for. - With both flag states, the
pageSingletonregistry holds the same 13 keys.
| Area | What I did | Result |
|---|---|---|
| Shortcuts | ?, and Keyboard shortcuts in the help menu |
One help modal each time |
| Shortcuts across lanes | l and g p on the MR page. The page's Vue 2 code adds its shortcut extensions to the instance that main built. |
One labels dropdown; navigation works |
| Autocomplete | /unassign @ and /assign @, in rich text and plain text |
Only the assignee, or everyone except the assignee |
| Markdown preview | Preview button, and Ctrl+Shift+P both ways |
Opens and closes; focus returns to the text area |
| Legacy preview | Preview on the snippet comment form (HAML) | One request; the preview does not close again |
| Mermaid | A preview and a posted comment | One sandbox iframe and one sandbox request per render |
| Image lightbox | Select the image in a comment | One lightbox |
| Reviewers | Assign yourself, then Re-request review | Saved on the server; one "Requested review" toast; one toaster |
| To-do count | Add a to-do item | The count in the left sidebar goes up by exactly 1 |
| Tooltips | Hover, then copy the branch name | One tooltip; "Copied" shows, hides, and the title is restored |
| Popovers | A data-toggle="popover" button added to the page |
One popover; it is removed with its trigger |
| Invite members | Open, search, close, open again | One modal; the search returns users |
| Work item | Load the page and post a comment | One shared Apollo client; the comment renders; no console errors |
Console messages
The stack causes no console errors.
- The Mermaid sandbox page logs Content Security Policy errors about the rspack hot reload websocket. The same errors appear on
master. They come from the GDK development setup. - The warnings are the same with the flag off and on: missing translations in
@gitlab/uiand@gitlab/duo-ui, theGlTokenSelectorlabel notice, andtippy.jsnotices when the comment editor switches between rich text and plain text. The translation warnings appear once per page, so the configuration runs once.
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.
References
- Builds on !263173 (merged), which detects module state in plain JavaScript
- Builds on !262801 (merged), which shares page-wide state between the Vue 2 and Vue 3 copies and replaces the blocklist commit of this MR
- Follows !261548 (merged), which made
mainits own entry - Part of #623999 — Vue 3 migration of global bundles and
main - Rollout issue for
vue3_migrate_main: #633362 - Replaces the runtime-switch approach in !261461 (closed) and !261501 (closed), both closed
- Build-time duplicated-modules check: !252666 (merged)
- Shared tooltips and popovers state: #628699
- The
vue3_runtimegroup comes from review: !261548 (comment 4006233255) - core-js duplication found while preparing !262048 (merged)





