Draft: Move main.js Vue apps to Vue 3 behind a flag

What does this MR do and why?

main.js runs on every page, and every Vue app it mounts is locked to Vue 2. The vue3_migration.yml mechanism cannot switch it, because main is prepended to every page entry and has no bundle of its own. A standalone main entry would fix that, but it changes the chunk graph of every page.

This MR moves all of main.js's Vue apps in one step, with no bundler config change. It uses Option 2 of the Vue 3 migration guide:

  • main_app.js holds every Vue app that main.js mounts: behaviors, user and work item popovers, the version check, the session expiry modal, panel resizers, and the EE trial banner and SAML modal.
  • When vue3_migrate_main is on, main.js imports main_app.js with ?vue3. If that import fails, the Vue 2 copy runs and the error goes to Sentry.
  • With the flag off, main.js calls the Vue 2 copy synchronously, at the same points as before.

Gitlab::GonHelper#add_gon_feature_flags pushes the flag with the current_user actor, because main.js runs on every page.

Nothing may run on import

The Vue 2 copy of main_app.js is a static import, so it is evaluated even when the flag is on. Any module below it that does work on import would do that work twice, once per runtime. Three did:

  • behaviors/index.js ran its inits at module scope.
  • toggler_behavior.js registered its jQuery handlers at module scope.
  • main_ee.js ran the trial banner and SAML modal inits at module scope.

They now export init functions, and main_app.js calls them. Their side-effect imports that are Vue-free or blocklisted stay, because those have one copy. main.js keeps import './behaviors' in its old position, so module evaluation order does not change.

Shared modules stay on one copy

A Vue 3 copy of a module does not share module state with its Vue 2 copy. Vue 2 page code imports some of the same modules as main, so this MR adds them to INFECTION_BLOCKLIST. A blocklisted module always builds as one Vue 2 copy:

  • tooltips/index.js and popovers/index.js: one app that main.js starts and page code calls
  • behaviors/shortcuts/index.js: one Shortcuts instance. Two copies bind every key twice.
  • vue_shared/plugins/global_toast.js: one toast root
  • behaviors/markdown/render_iframe.js and render_sandboxed_mermaid.js: a WeakMap that stops an element from rendering twice
  • lib/utils/axios_utils.js: interceptors registered on the one axios instance

These entries also apply to pages that already roll out through vue3_migration.yml. On those pages they remove existing duplicates. For example, axios interceptors stop running twice per request.

How the list was found

  • I simulated the change with the infection scanner's lane walk and the loader's real isInfectable rule:
    • Vue 2 lane: main.js without the moved code, every page entry, and the always-loaded bundles
    • Vue 3 lane: the moved code
  • Every module present in both lanes with module-level state was triaged by hand. These are harmless as two copies, so they are not blocklisted:
    • emoji (a cache)
    • issuable/popover (a debounce timer; the mounted flag lives in the DOM)
    • text_markdown (an IME flag per listener)
    • apollo_captcha_link (a stateless link)
    • internal_events (a bind guard)
  • A Babel pass then listed every module in the Vue 3 lane that runs code on import. Only module-local assignments remain: gfm_auto_complete in CE and EE, markdown/schema.js, and vue3compat/vue.js, which is per runtime by design.
  • The state detection is pattern-based. It missed shortcuts, which I found by reading the code. This pipeline runs every feature spec with the flag on, and that is the real test.

Result

  • About 49 components move to Vue 3.
  • 115 components below the moved code stay on Vue 2. All of them are held back by the existing behaviors/preview_markdown.js entry (glql, content_editor and others), exactly as on rollout pages today.
  • The Vue 2 runtime stays on every page, because ~/commons and Vue 2 page code still need it.

What was ruled out

  • One app at a time, which was the first version of this MR. It never reaches "main on Vue 3".
  • A switch inside the bundle, with static require of both copies. It ships both copies to every page and does not work under Vite.
  • A main.vue3 entry. It needs the standalone main entry above, or about 2,500 page entries.

Screenshots or screen recordings

Not recorded yet.

How to set up and validate locally

  1. Enable the flag in the Rails console:

    Feature.enable(:vue3_migrate_main)
  2. Open a project page, such as http://127.0.0.1:3000/gitlab-org/gitlab-test.

  3. In the console, check for [gitlab] [V] Using Vue.js 3 (with @vue/compat) for PanelWidthResizerRoot.

  4. Run document.querySelectorAll('[data-gitlab-vue3-app]'). It returns the panel resizer roots.

  5. Check that nothing runs twice:

    • Press ?. One shortcuts help modal opens.
    • Hover a link with a tooltip, then click it. The tooltip hides.
    • Click a collapsible section toggle. It opens once.
  6. Hover a user link. The user popover opens.

  7. Disable the flag and reload. The console shows no Vue 3 notice for these roots.

Local checks run:

  • yarn jest spec/frontend/config/vue3migration spec/frontend/scripts/infection_scanner spec/frontend/behaviors: 36 suites, 551 passed, 1 skipped.
  • node scripts/frontend/infection_scanner/infection_scanner.mjs: duplicated modules check, 685 page states, none found.
  • ESLint on every changed file.

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