Journal
Upgrading to Vue 3
For the past week and a half, I’ve been migrating the new parts of Cushion from Vue 2.6 to Vue 3. Upgrading has been on my to-do list ever since Vue 3 reached the point of being stable and supported enough with migration guides, but it hasn’t been the most crucial to-do for Cushion right now. Most might argue that it’s still not a high priority, but I had a recent thought that changed my mind. Since it’s inevitable that I upgrade Cushion to Vue 3, the more code I write for the older version, the more code I’ll need to migrate when I finally decide to upgrade. By upgrading now, I’m saving future me more time by sacrificing less time now.
While the migration guide is fantastic (as expected), I’d like to point out a few rough spots in the process that ended up consuming most of my time. Top on the list is changing v-model
from using a value
prop and input
event to modelValue
and update:modelValue
respectively. This change allows folks to have multiple v-model
’s on a single component, which is great, but for me, means a laborious migration—especially considering that input elements still use :value
and emit input
. Luckily, this isn’t complex work—just time-consuming. Nothing that can’t be handled over a few hours with Netflix in the background.
The next, and somewhat related, notable change is that $listeners
has been removed in Vue 3. Instead, listeners are now part of $attrs
, but prefixed with “on” (e.g., update:modelValue
is represented as onUpdate:modelValue
). The brunt of this migration work involved migrating how I test listeners in Jest using Vue Test Utils. Instead of specifying listeners
when mounting a component, I now use the attrs
prop, but use a rest operator to include the listeners:
const listeners = {
"onUpdate:modelValue": jest.fn(),
};
const wrapper = mount(MyComponent, {
attrs: {
...listeners,
},
});
While I understand why we need to prefix with “on”, it does break searches, if I wanted to find all the instances of a specific listener. I was also just getting in the habit of using enums for listeners in Vue 2.6, like TextInputEventName.input
, which could then be shared across instances, so I’m not relying on strings. There might be a friendlier way of handling this, like a helper method to prefix the “on” for attrs, but this was just a little hiccup in the process.
On a more positive note, Vue 3 supports teleporting, or the ability to render a child in a slot outside of the parent component. In Vue 2, this was only possible using the PortalVue, but now it’s natively supported, using <teleport to="...">
instead of <portal to="...">
. Along with the benefit of native support and one fewer dependency, it’s also much easier to test teleported children. Instead of mounting the portal then searching for the teleported child, you can simply search the original component wrapper:
const wrapper = mount(MyParentComponent);
const teleportedChild = wrapper.findComponent(TeleportedChild);
In order for portals to work in Jest, you still need to render the portal div. This is as easy as appending a div to the document.body
with the ID of the portal. For now, I’ve decided to take the easy (and lazy) approach of using a global beforeEach
, then destroying all the evidence in an afterEach
:
global.beforeEach(() => {
let el = document.createElement("div");
el.id = "ModalPortal";
document.body.appendChild(el);
});
global.afterEach(() => {
document.body.outerHTML = "";
});
The last point I’ll touch on is a hiccup that actually cost me a few days. I don’t imagine many others will experience this snag, but if they do, hopefully this blog post saves you some time. Upon upgrading to Vue 3, imports of *.vue
files in my Jest test files started raising TypeScript alerts, saying “Cannot find module for @/components/MyComponent.vue”, etc. In Vue 2, this was easily fixed with a shim:
declare module "*.vue" {
import Vue from "vue";
export default Vue;
}
In Vue 3, however, instead of export default Vue.extend({…})
in single-file Vue components, the syntax is export default defineComponent({…})
, which returns a DefineComponent
object. I searched for what the updated shim should be and found this:
/* eslint-disable */
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}
Even after updating my shim, the TypeScript error still existed. I spent days troubleshooting the issue, updating dependencies, trying to mimic a fresh Vue 3 app install, etc.—still no luck. Then, as I was desperately removing random code that might be conflicting, I discovered the culprit. When migrating to Vuex 4 along with the Vue 3 upgrade, I added a Vuex shim to include support for $store
within my components. As soon as I removed that, the TypeScript error went away. The Vuex error returned, but this was at least progress.
After a few more minutes of tinkering, I realized that these shims needed to live in their own TypeScript declaration files—I can’t put both the Vue and the Vuex shims within the same file. This might be obvious to many folks, but it wasn’t obvious to me. Now I have shim files for both Vue and Vuex.
Overall, I’m thrilled to finally be using Vue 3. My Vue components still use the Options API, not the fancy new composition API, but this initial upgrade to Vue 3 is a solid first step that had to be done all-at-once. From here, I can create new components using the composition API, and incrementally migrate the rest of the existing components at my own leisure. I am slightly nervous about having one big setup
function for each component versus everything organized into methods
and computed
objects, but I’m more excited about easily extracting shared composition functions that could hopefully keep those nerves at bay.