Upgrading from Vue 2 to Vue 3

According to the Vue roadmap, Vue 2 will only get one more minor release as an LTS version, supported for 18 months. This was back in September 2020, which means that a lot of Vue 2 projects will either have to switch to the migration build or to Vue 3 to ensure an up-to-date frontend framework.

Since it is a major upgrade, Vue 3 comes with breaking changes. First of all, Vue provides a migration build that should help with the transition to Vue 3, as it is compatible with Vue 2 while providing Vue 3 support. Instead of breaking at runtime, it will issue a deprecation warning so that the migration can be done step by step.

The following is not a complete list of all breaking changes (this list can be found here) and how to resolve them. Instead, it is a collection of things that stood out to me while upgrading.

Vue class components

In my case, the app running on Vue 2 relied on the vue-class-component dependency. This had to be upgraded first, since it is no longer compatible with Vue 3. I had to switch to release candidate version 8.0.0-rc.1 to make it work with Vue 3. However, this introduces some breaking changes. First and gforemost, @Component is now called @Options. Additionally, the @Prop annotation moved to a different module. See the example below.

Before:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import SomeComponent from "@/components/SomeComponent.vue";

@Component({
    components: {
        SomeComponent
    }
})
export default class SampleComponent extends Vue {
    @Prop({ required: true })
    someProp!: SomePropType;
    // ...
}
</script>

After:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<script lang="ts">
import { Vue, Options } from 'vue-class-component';
import { Prop } from 'vue-property-decorator';

@Options({
    components: {
        SomeComponent
    }
})
export default class SampleComponent extends Vue {
    // same as above
</script>

Registering component hooks

If you are using Vue hooks like beforeRouteEnter(), this now has to be done via the Vue object.

Before:

1
2
3
import Component from 'vue-class-component';

Component.registerHooks(['beforeRouteEnter']);

After:

1
2
3
import Vue from 'vue-class-component';

Vue.registerHooks(['beforeRouteEnter']);

Vue.set() no longer needed

In Vue 2 it was required to use Vue.set() in case you wanted to keep some data object reactive while adding new properties. This is no longer required in Vue 3.

Before:

1
2
3
addSomeProperty(someData) {
    Vue.set(this.someObject, 'someProperty', someData);
}

After:

1
2
3
addSomeProperty(someData) {
    this.someObject.someProperty = someData;
}

Router

The vue-router also made some changes to our code necessary in order for it to work like it did before the upgrade.

Before:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import VueRouter, { RouteConfig } from 'vue-router';

const router = new VueRouter({
	routes: [
        {
            path: '/'
            component: EntryPage
        }
    ]
})
export default router;

After:

1
2
3
4
5
6
import { createRouter, createWebHashHistory } from 'vue-router';

export default createRouter({
    history: createWebHashHistory(),
    routes
})

Vue-Test-Utils

The @vue/test-utils dependency needed to be updated from v1 to v2 to be compatible with Vue 3. You can find the full list of breaking changes in the test-utils migration guide.

Here are some of the changes I found most important.

Props

Props are now passed via a props object.

Before:

1
2
3
4
5
shallowMount(SomeComponent, {
    propsData: {
        someProp
    }
});

After:

1
2
3
4
5
shallowMount(SomeComponent, {
    props: {
        someProp
    }
});

Rendering stub slots

By default, shallowMount stubs out all custom components. However, in Vue 2, it still rendered the default stub. To keep this feature, make use of the configuration option config.renderStubDefaultSlot = true.

This can either be done in a separate test:

1
2
3
4
5
6
7
8
9
import { config } from '@vue/test-utils';

beforeAll(() => {
    config.renderStubDefaultSlot = true;
});

afterAll(() => {
    config.renderStubDefaultSlot = false;
});

Or enabled globally by importing the config object in the test setup file and setting the renderStubDefaultSlot option to true there.

Global object

There is a new global object that needs to contain mocks and stubs for tests. If, for example, you want to use a mocked router, you might want to do it like this:

1
2
3
const mockedRouter = {
    push: jest.fn()
};

Before:

1
2
3
4
5
shallowMount(SomeComponent, {
    mocks: {
        $router: mockedRouter
    }
})

After:

1
2
3
4
5
shallowMount(SomeComponent, {
    global: {
        $router: mockedRouter
    }
})

Emitting Events

DOM events can still be triggered. For example, simulating a button click in a test can still be done like this:

1
await wrapper.find('.some-button').trigger('buttonClicked');

Notice how trigger returns a promise.

However, in my case, I specifically had to test a lot of custom components that emit events. To test this, I often resorted to something like:

1
2
wrapper.findComponent(SomeCustomCompoentn).vm.$emit('optionsClicked');
await wrapper.vm.$nextTick();

In contrast to trigger(), $emit() does not return a promise. So to make sure that the code in the background is settled before moving on, I wait for the next tick before making any assertions. Notice also the use of findComponent() instead of find(). This is necessary since SomeCustomComponent is a web component. find() should only be used to retrieve regular elements.

Where to go from here

Vue 3 comes with several improvements and new features. To separate concerns, I would recommend staying away from implementing new features while performing the migration. However, after the migration is done, it is advisable to look into the new features. Especially the new composition API has the potential to simplify how components are implemented.