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:
- Fix: this MR.
- Enforce: !263173 (merged) makes the build fail when a new module like these gets two copies.
- Migrate
main: !261499 (merged) movesmainto 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 avue3_migration.ymlrollout runs its own code on Vue 3, whilemainstays on Vue 2. - With the flag from !261499 (merged) on, it happens on every page:
mainruns 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
-
While preparing !261499 (merged), I simulated the rollout pages with the lane walk of the infection scanner.
-
I searched the modules that get two copies for state that the singleton detector of the scanner does not see:
- module-level
let WeakMapandnewinstances- listeners and interceptors added at import time
This found 7 modules.
- module-level
-
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
maincould move to Vue 3, but the tooltips, shortcuts and toast could not. - The blocklist on
masteralready had the same problem for 4 other modules.
-
This MR shares the state instead, and empties the blocklist.
The fix: page_singleton
- The new module
lib/utils/page_singleton.jskeeps page-wide state. - Its registry is on
globalThis, under aSymbol.forkey.lib/utils/observable.jskeeps 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 loadsmainbefore the page entry. - The code of each module is still built twice. Only the state is shared.
- The registry is global, so
page_singleton.jsworks 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.singletonandSidebarService.singletonstay static properties, now with a getter and a setter. Sogfm_auto_complete.js,content_editor.vueand 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
mastercode.
- !263183 makes those two readers use
super_sidebar/user_counts_manager.jsalready shared its listener registration through its ownglobalThiskey. It now usespageSingleton, with no change in behaviour. ItsuserCountsstate stays onobservable(), 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 addsdocumentlisteners 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.vueandbehaviors/shortcuts/shortcuts.jsimport it only for theMARKDOWN_EVENT_*names. These names now live inbehaviors/preview_markdown_events.js, which imports nothing.dropzone_input.jsimported it only for its listeners.main.jsalready adds them on every page, so the import is gone.- Now only
main.jsloadspreview_markdown.js, and the module keeps itsmastercode. The migration guide describes this case.
commons/gitlab_ui.js: it never reached Vue. The scanner marks itexposedToVue: false, so the loader already built it once. The entry did nothing.commons/duo_ui.js: it reached Vue only because it importedsetConfigsfrom the index of the@gitlab/duo-uipackage.- 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.jsalready does with@gitlab/ui/src/config. - So it builds once, and every copy of Duo UI reads the same translations.
- The registry is in
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
mainmoves to Vue 3.maincould never drop Vue 2.- With this MR, these modules use the Vue version of
main. With thevue3_migrate_mainflag 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.
- With this MR, these modules use the Vue version of
- Use
observable().lib/utils/observable.jshas 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.mjsThe scanner passes with an empty blocklist. It marks these modules with exposedToVue: false:
page_singleton.jsbehaviors/preview_markdown_events.jscommons/duo_ui.jscommons/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
mainon Vue 3 next to every Vue 2 page. - With the detector from !263173 (merged) on top: the scanner still finds the
documentlisteners inpreview_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.js76 times. The old blocklist entry for the sidebar mediator had hidden it. It now usespage_singletontoo. - 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/pluginsspec/frontend/behaviorsspec/frontend/lib/utilsspec/frontend/sidebar,ee/spec/frontend/sidebarspec/frontend/super_sidebarspec/frontend/invite_membersspec/frontend/graphql_sharedspec/frontend/commonsspec/frontend/vue_shared/components/markdown,spec/frontend/vue_shared/components/content_viewerspec/frontend/dropzone_input_spec.js,spec/frontend/gl_form_spec.js,spec/frontend/zen_mode_spec.js,spec/frontend/notes/deprecated_notes_spec.jsspec/frontend/blob_edit,spec/frontend/snippets/componentsspec/frontend/vue_shared/issuable/createspec/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.mjsnow takes its path fromINFECTION_BLOCKLIST, and runs only when the list has an entry. - It is skipped here. It runs with
commons/vue.jsin !261499 (merged).
- The blocklist example in
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
?vue3copy of tooltips, toast, shortcuts,axios_utils,render_iframeandrender_sandboxed_mermaid. - Tooltips: there is one tooltips container.
- The Vue 3 copy can hide, show and dispose a tooltip that
maincreated. - The Vue 2 copy can show and dispose a tooltip that the Vue 3 copy added.
- The Vue 3 copy can hide, show and dispose a tooltip that
- 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.jsadds. - Duo UI:
@gitlab/duo-ui/dist/config.jsloads once, with no?vue3copy. - The console shows no errors.
Not covered:
- On these pages, the sidebar, the invite modal, popovers and
issuable_default_clientload only once. They get a second copy when !261499 (merged) movesmainto 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
mainto 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
pageSingletonis no longer needed there - Follow-up: #634479 removes the remaining
Vue.extendcalls, including the one inpopovers/index.js - Found while reviewing the tooltips API: #634480