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_FORCELIST and the explicit ?vue3 dynamic 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, the isEditionAbsent helper, its unit tests, and the hint in the duplicated-modules report. All four entries are exposedToVue: true. The duplicated-modules report is identical with and without the list.
  • In app/assets/javascripts/ide/init_gitlab_web_ide.js and app/assets/javascripts/ide/mount_oauth_callback.js: the explicit ?vue3 dynamic imports behind the vue3_migrate_web_ide feature flag, their Sentry fallback, and the Jest cases for the flag-on branch. Both modules are exposedToVue: 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.js reaches a Vue app root through the captcha link. Two copies ran Object.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 in app/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, and wait_for_requests would return early. Blocklisting lib/graphql.js was rejected: it would put the captcha modal in the Vue 2 realm.
  • SidebarMediator in app/assets/javascripts/sidebar/sidebar_mediator.js is a singleton class. The Vue 3 sidebar app creates it. gfm_auto_complete.js in the Vue 2 main.js bundle 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 in sidebar/services/sidebar_service.js below it, on 46 page states. The mediator is that service's only importer and runs no Vue-version-specific setup, so it is added to INFECTION_BLOCKLIST. Cost: createAlert and toast, 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.js imports workflow_utils, which is exposed to Vue. The Duo panel in main_ee.js emitted 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 to events/event_hub_instance.js, which imports only ~/helpers/event_hub_factory, so it is not exposed to Vue. The public API of event_hub.js is 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-scroller runs in a Vue 3 realm for the first time, on four pages. Commit e7c54a093eb1 made one copy serve both runtimes, so this should hold. build-vite-prod is the gate. Read the log of build-vite-prod-vue3, because that job has allow_failure: true.
  • Bundle size: compare the compile-production-assets webpack report before and after.

Follow-up, not in this MR

  • vendor/assets/javascripts/vue-virtual-scroller/src/mixins/IdState.js is detected as an app root but is a mixin factory that holds reactive state through new 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 error

The 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

Edited by Miguel Rincon

Merge request reports

Loading
Loading