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.yml declares status: rollout with vue3_migrate_main. With the flag on, the layout serves the main.vue3 bundle instead of main, in Webpack, Rspack and Vite. !261548 (merged) prepared this: it made main its own entry and renders it through webpack_bundle_tag in every layout.
  • commons/vue.js joins INFECTION_BLOCKLIST. This is the one entry the blocklist is right for. The module installs the global Vue 2 plugins, such as Translate, and Vue 2 apps still need them when main runs on Vue 3. Without it, Vue 2 apps fail with _vm.__ is not a function. The Vue 3 lane gets the same plugins from vue3compat/vue.js.
  • The infection scanner checks one more state: main on Vue 3 next to every page that still runs Vue 2.
  • A vue3_runtime cache group keeps @vue/compat in one chunk, in Rspack and Webpack. The super sidebar lazy-loads global_search_modal and shortcutsBundle on every page, and they bring the Vue 3 runtime. Without the group, main.vue3 loaded a second copy of it.
  • The migration guide treats main as a global bundle with its own vue3_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 with main.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, WeakMap or new instances, listeners added at import time, interceptors, window writes and calls at import time. 16 modules match. For each one, the state is harmless:
    • rails_ujs.js starts Rails UJS once, guarded by window._rails_loaded.
    • The issuable popovers guard on DOM attributes.
    • Only main binds internal_events and CopyAsGFM to the document.
    • ai/utils.js writes to ee/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 include ff-inverse jobs that turn vue3_migrate_main on, 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 with main on 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)
main_off-merge_request main_on-merge_request
main_off-pipeline main_on-pipeline
main_off-project main_on-project

How to set up and validate locally

  1. With GDK on Rspack, check out this branch and run gdk restart rspack.
  2. Run Feature.enable(:vue3_migrate_main).
  3. Open any page. The page source loads main.vue3 in place of main.
  4. Run document.querySelectorAll('#gl-tooltips-app').length. It returns 1 after you hover a few tooltips.
  5. 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 in infection_spec.mjs, which runs with commons/vue.js.
  • yarn infection-scanner: duplicated modules check, 706 page states, none found. commons/vue.js is 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_main on, 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:

  1. It checks which main bundle the page loads.
  2. It hovers a tooltip probe and a popover probe, and presses ?.
  3. It finds every Vue root and its runtime, and reads the pageSingleton registry.
  4. 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 loads main.vue3.bundle.js.
  • TooltipsRoot, PopoversRoot and ShortcutsHelpRoot each 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 in main_on.
  • One tooltip, one popover and one keyboard shortcuts modal open.
  • The pageSingleton registry 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 warning Invalid prop: type check failed for prop "link" from CodeDropdownCloneItem on the snippet page.
    • sandboxed_mermaid_spec.rb: #app > svg never renders in the sandbox.
    • todos_spec.rb:460: the list does not reload in time.
  • math_spec.rb fails on a different example in each run. Its requests do not settle.
  • I reran dashboard/shortcuts_spec.rb alone 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 pageSingleton registry 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/ui and @gitlab/duo-ui, the GlTokenSelector label notice, and tippy.js notices 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

Edited by Miguel Rincon

Merge request reports

Loading
Loading