If you’ve been in the Vue ecosystem for a while, you know that the jump from Vue 2 to Vue 3 is more than just a version bump – it’s a significant architectural evolution. It promises better performance, a more powerful Composition API, and a first-class TypeScript experience. Getting there from an old Vue 2 project can feel like navigating a maze of breaking changes, new build tools, and refactoring
rabbit holes – and you’re right.
We recently guided one of our Icinga Web 2 modules through this very process. And so to help other developers on the same path, below I’ll share some quick notes about some of the things we had to do to make this happen.
Our codebase, Alyvix UI, contains around 20k lines of code in the form of TypeScript and Vue files. The migration was necessary in order to be able to use a new module which we’ll include in our upcoming Alyvix UI new features.
We collectively decided that it was best to stick to Vue 2’s Options API and not migrate to Vue 3’s Composition API. Thankfully, there’s an existing project, vue-facing-decorator, which allows us to continue using the Options API in our new Vue 3 codebase, dramatically decreasing the amount of changes required.
Our best decision was the very first one: we decided to create a new project folder, alyvix-ui-vue3, alongside our existing alyvix-ui folder. We initialized the empty project using npm create vue@latest in order to have a working ecosystem right out of the box.
The goal of this setup is to be able to migrate one file at a time by copying it from alyvix-ui to alyvix-ui-vue3.
We started by copying package.json to the new folder, then src/App.vue, then router/* etc. We iteratively copied one file at a time, tried to compile it, and solved each resulting error as it came up. If a Vue component relied on too many other files, we commented them out instead of migrating them all in one batch.
The actual main goal of the two-folder system is to be able to easily review pull requests.
Before creating a pull request on a feature branch, we copied all the contents of alyvix-ui-vue3 to the old alyvix-ui folder. Since the vue3 folder contains only the files that have been migrated, the resulting alyvix-ui folder will contain the original files plus the migrated sources.
That way we were able to see the differences in the PR review between the old code and the new by looking at the diffs in the alyvix-ui folder.
If we were to look only at the diffs of the new folder, then we would see thousands of new lines of code, 90% of which were copy-pasted from old files.
A questionable choice from our end was to leave the migration of the tests to the end.
Looking back, some really weird errors emerged which were either actual problems with the UI or, more likely, problems with Vitest.
The old suite used jest, while the new one uses Vitest. At its core, it was as simple as following the official migration guide (https://vitest.dev/guide/migration.html#jest). Some notable mentions:
jest.* occurrences with vi.*new VueRouter() occurrences to createRouter() logicmount syntax (watch out: you won’t get compilation errors if you mess up mount’s config argument. E.g. props != propsData)We used Vitest UI to quickly skim through tests and instantly get feedback about which error is next.
A really troublesome issue, which gave us hours of headaches, was using Carbon Design System’s components. These components, especially CvCheckboxes and CvTriggers, rely on a chain of "emit“s, “triggers" and “for=..." patterns that got silently messed up when migrated to the new Vitest’s JSDom.
As an example, there were some clicks that just “didn’t happen”, even though the UI (npm run dev) worked fine and there was absolutely no issue regarding HTML element lookup and rendering.
And for reference, here’s the vitest.setup.ts file that made our CDS-reliant tests work:
import CarbonVue3 from "@carbon/vue";
import { createTestingPinia } from "@pinia/testing";
import { config } from "@vue/test-utils";
config.global.plugins = [
CarbonVue3,
createTestingPinia({
stubActions: false,
stubPatch: false,
stubReset: false,
})
];
// Some CDS components have internal <Teleport>s, such as the CvModals. With this, we render those sections
// inline instead of teleporting them away.
config.global.stubs = {
teleport: true,
}
config.global.mocks = {
$t: (a: string) => a
};