Give Vue 3 pages Vue 3 copies of modules above app roots
Why
GitLab migrates pages from Vue 2 to Vue 3 behind feature flags. A migrated page runs Vue 3. The rest of the app, including global bundles such as main.js, runs Vue 2.
The bundler builds a separate copy of a module for each Vue version when the module needs it. We call such a module "infected". The infection scanner in scripts/frontend/infection_scanner/ decides this and writes a graph to tmp/infection_scanner.json. It marks a module infected when the module imports Vue, or imports something infected. That walk stops at an "app root", a module that mounts its own Vue app. Without the stop, importing any app bootstrap file would infect the importer and spread infection across the repository.
The bundler predicate createIsInfectable in config/helpers/vue3_infection_shared.js reused the infected flag to answer a different question: a Vue 3 page imports this module, so must the copy be the Vue 3 one? For a module above an app root that imports no Vue itself, the answer is yes. The stop made the answer no.
So that module resolved to the shared Vue 2 copy, and everything below it did too. Part of a page marked as migrated silently ran Vue 2. Modules with module-scope state in that subtree loaded twice, once per Vue version. Nothing reported it.
Measured on this branch: 275 modules were exposed to Vue but ran as Vue 2 inside a migrated page. Only 8 of them held state the duplicated-modules check can detect. The other 267 were invisible.
The manual workaround was INFECTION_FORCELIST in config/helpers/context_aliases_shared.js. Someone had to notice each case and add an entry.
What
The scanner now emits a second flag per module, exposedToVue. It is the same walk as infected but with no stop at app roots. It means "some import path from this module leads to Vue".
The bundler predicate reads exposedToVue. The infected flag keeps its meaning for entrypoint promotion, the entry stats, and the scanner web UI.
The MR also:
- removes the two workarounds the stop had forced:
INFECTION_FORCELISTand the explicit?vue3dynamic imports in the Web IDE. - keeps three module-scope singletons single that the change would otherwise duplicate.
How
infected is always a subset of exposedToVue, because removing a stop can only add modules. The scanner asserts this on every run.
computeExposedToVue is a thin wrapper that calls computeInfected with an empty app-root set. computeInfected has about ten unit tests that pin its semantics. A new parameter would blur which question a caller asks.
The flag is emitted by the scanner, not derived later. Removing a stop is not a local operation on one node.
loadScannerData throws when scanner data predates the flag. It does not fall back to infected. A fallback would read the missing flag as false, mark every module clean, and silently render every Vue 3 page with Vue 2.
Effect in the EE edition: infected marks 6244 modules, exposedToVue marks 6975. So 731 more modules get a per-version copy. Per page the Vue 3 module graph grows by a median of about 21 modules. The largest growth is 570 on the merge request pages.
Removed workarounds
INFECTION_FORCELIST, its validation in the bundler predicate, theisEditionAbsenthelper, its unit tests, and the hint in the duplicated-modules report. All four entries areexposedToVue: true. The duplicated-modules report is identical with and without the list.- In
app/assets/javascripts/ide/init_gitlab_web_ide.jsandapp/assets/javascripts/ide/mount_oauth_callback.js: the explicit?vue3dynamic imports behind thevue3_migrate_web_idefeature flag, their Sentry fallback, and the Jest cases for the flag-on branch. Both modules areexposedToVue: true, so their static imports resolve to Vue 3 inside the Vue 3 page. With the flag off, the page loads the Vue 2 entrypoint.
Singletons kept single
The duplicated-modules check, merged in !252666 (merged), fails compile-production-assets and build-vite-prod when a page loads a module with module-scope state in both Vue lanes. The new flag exposed three such modules:
app/assets/javascripts/lib/graphql.jsreaches a Vue app root through the captcha link. Two copies ranObject.defineProperty(window, 'pendingApolloRequests', ...)twice. The property is not configurable, so every Vue 3 page threw and 50 system spec shards failed. The counter now lives inapp/assets/javascripts/lib/graphql_pending_requests.js, which imports nothing, so it is never duplicated. Making the property configurable was rejected: the second getter would count only its own clients, andwait_for_requestswould return early. Blocklistinglib/graphql.jswas rejected: it would put the captcha modal in the Vue 2 realm.SidebarMediatorinapp/assets/javascripts/sidebar/sidebar_mediator.jsis a singleton class. The Vue 3 sidebar app creates it.gfm_auto_complete.jsin the Vue 2main.jsbundle reads its store to rank current assignees. Two copies mean the Vue 2 reader sees no singleton. The check cannot detect a split class static. It only reported the Apollo client insidebar/services/sidebar_service.jsbelow it, on 46 page states. The mediator is that service's only importer and runs no Vue-version-specific setup, so it is added toINFECTION_BLOCKLIST. Cost:createAlertandtoast, which the mediator calls, mount with Vue 2 inside a Vue 3 page. Both are self-contained app roots.- The Duo agentic chat event hub in
ee/app/assets/javascripts/ai/duo_agentic_chat/events/event_hub.jsimportsworkflow_utils, which is exposed to Vue. The Duo panel inmain_ee.jsemitted tool events through the Vue 2 copy, while widgets in Vue 3 pages subscribed through the Vue 3 copy, so events never arrived. Reported on 10 page states. The hub instance moved toevents/event_hub_instance.js, which imports only~/helpers/event_hub_factory, so it is not exposed to Vue. The public API ofevent_hub.jsis unchanged.
Docs: the "Module-scope singletons" section of doc/development/fe_guide/vue3_migration.md no longer points to the forcelist. The usual fix is now to move state into a module that is not exposed to Vue. Adding it to INFECTION_BLOCKLIST is the alternative. After this change only a blocklist entry can be a "sink", the module that reverts a subtree to Vue 2.
Result: the scanner reports no duplicated modules on 381 EE page states and 252 CE page states.
Not yet verified
- The vendored
vue-virtual-scrollerruns in a Vue 3 realm for the first time, on four pages. Commite7c54a093eb1made one copy serve both runtimes, so this should hold.build-vite-prodis the gate. Read the log ofbuild-vite-prod-vue3, because that job hasallow_failure: true. - Bundle size: compare the
compile-production-assetswebpack report before and after.
Follow-up, not in this MR
vendor/assets/javascripts/vue-virtual-scroller/src/mixins/IdState.jsis detected as an app root but is a mixin factory that holds reactive state throughnew Vue({ data }).
How to verify
node scripts/frontend/infection_scanner/infection_scanner.mjs
FOSS_ONLY=true node scripts/frontend/infection_scanner/infection_scanner.mjs
yarn vitest:infection-scanner
rm -f tmp/infection_scanner.json # then run a build to exercise the stale-data errorThe six existing app_root_barrier assertions on infected pass unchanged, which shows the infected semantics did not change. No changelog entry: this is build tooling with no user-facing change.
References
- Fixes #625296 (closed)
- Root cause analysis: #625296 (comment 3760799545)
- Manual workaround this replaces, merged: !252665 (merged)
- Duplicated-modules check that reports the two blockers, merged: !252666 (merged)
- Fixed five duplications the check found, merged: !253161 (merged)
- Runs the scanner specs in CI, still open: !252664 (closed)
- Vendored scroller consolidation: commit
e7c54a093eb1 - Added the Web IDE
?vue3workaround this MR removes, merged: !251879 (merged) - Web IDE migration flag, whose comment asked for that removal: #618744 (closed)