Share page-wide state between Vue 2 and Vue 3 copies

What does this MR do and why?

TLDR; Clears the INFECTION_BLOCKLIST which forced modules to stay in Vue 2.

  • This MR shares page-wide state between the Vue 2 and Vue 3 copies of a module. Then no module needs the blocklist.
  • The public APIs of the changed modules do not change.
  • It is the first MR of a stack of three:
    1. Fix: this MR.
    2. Enforce: !263173 (merged) makes the build fail when a new module like these gets two copies.
    3. Migrate main: !261499 (merged) moves main to Vue 3 behind a flag.

The problem

  • 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 vue3_migration.yml rollout runs its own code on Vue 3, while main stays on Vue 2.
  • With the flag from !261499 (merged) on, it happens on every page: main runs on Vue 3 next to a Vue 2 page entry.
  • Some modules hold state that must exist once per page, for example the tooltips app or the axios interceptors. Two copies of that state cause bugs. The tables below list them.

How the problem was found

  1. While preparing !261499 (merged), I simulated the rollout pages with the lane walk of the infection scanner.

  2. I searched the modules that get two copies for state that the singleton detector of the scanner does not see:

    • module-level let
    • WeakMap and new instances
    • listeners and interceptors added at import time

    This found 7 modules.

  3. The first fix added them to INFECTION_BLOCKLIST. Review found a problem with that:

    • A blocklisted module and its whole import tree always build as Vue 2.
    • So main could move to Vue 3, but the tooltips, shortcuts and toast could not.
    • The blocklist on master already had the same problem for 4 other modules.
  4. This MR shares the state instead, and empties the blocklist.

The fix: page_singleton

  • The new module lib/utils/page_singleton.js keeps page-wide state.
  • Its registry is on globalThis, under a Symbol.for key. lib/utils/observable.js keeps its registry the same way. So both copies share the state.
  • The first copy that runs creates the state. That is the copy from main.js, because the layout loads main before the page entry.
  • The code of each module is still built twice. Only the state is shared.
  • The registry is global, so page_singleton.js works even if it is built twice. It needs no special check.

These 7 modules had no protection on master:

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. Keys are not bound twice: Mousetrap replaces a binding for the same keys.
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

These 3 were on INFECTION_BLOCKLIST:

Module State
invite_members/init_invite_members_modal.js the one mounted invite modal
graphql_shared/issuable_default_client.js the one work item Apollo client and cache
sidebar/sidebar_mediator.js, sidebar/services/sidebar_service.js SidebarMediator.singleton, SidebarService.singleton and the GraphQL client of the service
  • SidebarMediator.singleton and SidebarService.singleton stay static properties, now with a getter and a setter. So gfm_auto_complete.js, content_editor.vue and the specs that reset them do not change.
    • !263183 makes those two readers use SidebarStore.singleton, which is built once.
    • After both MRs merge, the mediator and the service can go back to their master code.
  • super_sidebar/user_counts_manager.js already shared its listener registration through its own globalThis key. It now uses pageSingleton, with no change in behaviour. Its userCounts state stays on observable(), because that state is reactive and needs one mirror per Vue runtime.

The other blocklist entries

These 3 entries did not need shared state:

  • behaviors/preview_markdown.js: it adds document listeners at import time. With two copies, one click on "Preview" opens the preview and closes it again. But no Vue code needs the module itself:
    • vue_shared/components/markdown/header.vue and behaviors/shortcuts/shortcuts.js import it only for the MARKDOWN_EVENT_* names. These names now live in behaviors/preview_markdown_events.js, which imports nothing.
    • dropzone_input.js imported it only for its listeners. main.js already adds them on every page, so the import is gone.
    • Now only main.js loads preview_markdown.js, and the module keeps its master code. The migration guide describes this case.
  • commons/gitlab_ui.js: it never reached Vue. The scanner marks it exposedToVue: false, so the loader already built it once. The entry did nothing.
  • commons/duo_ui.js: it reached Vue only because it imported setConfigs from the index of the @gitlab/duo-ui package.
    • The registry is in @gitlab/duo-ui/dist/config.js, which does not reach Vue.
    • The file now imports that module directly, as gitlab_ui.js already does with @gitlab/ui/src/config.
    • So it builds once, and every copy of Duo UI reads the same translations.

INFECTION_BLOCKLIST is now empty. The migration guide now recommends page_singleton, and keeps the blocklist as a last resort.

Alternatives ruled out

  • Keep the blocklist. A blocklisted module and all its imports always build as Vue 2. Then the tooltips, shortcuts help, toast, invite modal and sidebar render with Vue 2 on every page, also after main moves to Vue 3. main could never drop Vue 2.
    • With this MR, these modules use the Vue version of main. With the vue3_migrate_main flag off, they use Vue 2. With the flag on, they use Vue 3.
    • !261499 (merged) drops its blocklist commit. Its only entry is commons/vue.js, which installs Vue 2 plugins and must stay on Vue 2.
  • Use observable(). lib/utils/observable.js has the same keyed registry, but it is for reactive state. It imports Vue and makes a reactive mirror per runtime. The state in this MR is an axios setup, Apollo clients, mounted apps and class instances:
    • This state must not become reactive.
    • The mirror copy drops their prototypes.

How to set up and validate locally

node scripts/frontend/infection_scanner/infection_scanner.mjs

The scanner passes with an empty blocklist. It marks these modules with exposedToVue: false:

  • page_singleton.js
  • behaviors/preview_markdown_events.js
  • commons/duo_ui.js
  • commons/gitlab_ui.js

Infection scanner

  • On this MR: 695 page states, none found.
  • With !261499 (merged) on top: 696 page states, none found. The extra state is main on Vue 3 next to every Vue 2 page.
  • With the detector from !263173 (merged) on top: the scanner still finds the document listeners in preview_markdown.js, and reports no duplicate. So no page state loads it twice.
  • When I emptied the blocklist, the first scanner run flagged the GraphQL client in sidebar_service.js 76 times. The old blocklist entry for the sidebar mediator had hidden it. It now uses page_singleton too.
  • On this MR, the singleton detector does not see the state of the 7 modules that were not on the blocklist.
    • For those, the pattern search in !261499 (merged) is the check.
    • !263173 (merged) extends the detector, so that it finds all 12 modules against their code on master.

Specs

  • Jest, on Vue 2 and with VUE_VERSION=3: 358 suites passed in each lane. The specs are under these paths:
    • spec/frontend/tooltips, spec/frontend/popovers, spec/frontend/vue_shared/plugins
    • spec/frontend/behaviors
    • spec/frontend/lib/utils
    • spec/frontend/sidebar, ee/spec/frontend/sidebar
    • spec/frontend/super_sidebar
    • spec/frontend/invite_members
    • spec/frontend/graphql_shared
    • spec/frontend/commons
    • spec/frontend/vue_shared/components/markdown, spec/frontend/vue_shared/components/content_viewer
    • spec/frontend/dropzone_input_spec.js, spec/frontend/gl_form_spec.js, spec/frontend/zen_mode_spec.js, spec/frontend/notes/deprecated_notes_spec.js
    • spec/frontend/blob_edit, spec/frontend/snippets/components
    • spec/frontend/vue_shared/issuable/create
    • spec/frontend/gfm_auto_complete_spec.js
  • yarn jest spec/frontend/config: 52 tests passed.
  • yarn vitest:vue3migration run: 4 files passed.
    • The blocklist example in infection_spec.mjs now takes its path from INFECTION_BLOCKLIST, and runs only when the list has an entry.
    • It is skipped here. It runs with commons/vue.js in !261499 (merged).

Browser

I checked GDK (rspack) at 1881f66a, with vue3_migrate_work_items and vue3_migrate_project_overview on. On the work item page and on the project overview, the page entry runs on Vue 3 and main runs on Vue 2.

  • The bundle has a Vue 2 copy and a ?vue3 copy of tooltips, toast, shortcuts, axios_utils, render_iframe and render_sandboxed_mermaid.
  • Tooltips: there is one tooltips container.
    • The Vue 3 copy can hide, show and dispose a tooltip that main created.
    • The Vue 2 copy can show and dispose a tooltip that the Vue 3 copy added.
  • Shortcuts: ? opens one help modal.
  • Toast: there is one toast root. "Copy reference" shows one toast.
  • axios: both copies use one instance, with the 2 request interceptors that axios_utils.js adds.
  • Duo UI: @gitlab/duo-ui/dist/config.js loads once, with no ?vue3 copy.
  • The console shows no errors.

Not covered:

  • On these pages, the sidebar, the invite modal, popovers and issuable_default_client load only once. They get a second copy when !261499 (merged) moves main to Vue 3.
  • I did not check under Vite.

Skipped hook

The local gettext pre-push hook wants to drop the trailing blank line of locale/gitlab.pot. This MR adds and removes no strings, so I skipped that one hook.

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

  • Part of #623999 — Vue 3 migration of global bundles and main
  • !263173 (merged) is stacked on this MR. It detects module state in plain JavaScript, so the build enforces this fix
  • !261499 (merged) is stacked on that MR. It moves main to Vue 3 behind a flag, and this MR replaces its blocklist commit
  • Shared tooltips and popovers state: #628699
  • Follow-up from the review of this MR: !263183 reads the sidebar store directly in autocomplete
  • Follow-up: #634477 separates the shortcuts service from its help modal, so pageSingleton is no longer needed there
  • Follow-up: #634479 removes the remaining Vue.extend calls, including the one in popovers/index.js
  • Found while reviewing the tooltips API: #634480
Edited by Miguel Rincon

Merge request reports

Loading
Loading