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.