Create your event Book a demo Sign in
Engineering

Migrating 900 Vue components to the Composition API

Gabriel Robert
Gabriel Robert

Published on 13 Sept 2026

Between February and August 2026, we migrated about 900 Vue components from class syntax to <script setup>. An agent handled the rewrites. We built a pipeline around tests, reactive state comparisons and rules for when a migration had to stop for human review. One bug reached production: our test fixtures supplied a different shape of data.

Where we started

The Fourwaves organizer dashboard, event websites, virtual platform and shared components used vue-facing-decorator. We were already on Vue 3, but our components still used classes.

Here is an illustrative example. The template is omitted:

import { Component, Prop, Vue, Watch } from 'vue-facing-decorator';

@Component
export default class DataTable extends Vue {
  @Prop({ type: Array, required: true }) readonly items!: string[];

  query = '';
  page = 1;

  get visibleItems(): string[] {
    const query = this.query.trim().toLowerCase();
    const start = (this.page - 1) * 10;
    return this.items
      .filter((item) => item.toLowerCase().includes(query))
      .slice(start, start + 10);
  }

  @Watch('query')
  resetPage(): void {
    this.page = 1;
  }
}

The equivalent logic inside <script setup> uses refs, a computed value and a watcher:

import { computed, ref, watch } from 'vue';

const props = defineProps<{ items: string[] }>();

const query = ref('');
const page = ref(1);

const visibleItems = computed<string[]>(() => {
  const search = query.value.trim().toLowerCase();
  const start = (page.value - 1) * 10;
  return props.items
    .filter((item) => item.toLowerCase().includes(search))
    .slice(start, start + 10);
});

watch(query, () => {
  page.value = 1;
});

For a component like this, the translation is straightforward. We wanted the rest of the codebase to use Vue’s standard APIs too, with props and computed values declared directly instead of translated through decorators. The difficult cases involved mixins, injected values and parent components reaching into a child’s state.

The apps also run registration payments and peer review assignments for academic conferences. We kept shipping features during the migration, so each change had to fit into the normal PR and CI workflow.

The pipeline

The migration pipeline is a Claude Code skill: a Markdown file with numbered phases for the agent to follow. We revised it as migrations exposed missing checks. The process below describes the version we ended up with; those safeguards were not all present in February.

Each run takes a component from the backlog and follows these steps:

  1. Take the first unchecked component from a ranked backlog.
  2. Review coverage and mutation results, then add tests for gaps with user-facing consequences.
  3. Record the component’s reactive state while the tests run. This is the Options API baseline.
  4. Migrate to <script setup>.
  5. Run tests, typecheck and lint. Record the same state again and diff it against the baseline.
  6. Fix unexplained divergences. If they persist, revert the migration and keep only the new tests.
  7. Open a pull request, wait for CI, and score the PR against a mergeability rubric. At 90 or higher it merges; below that, it waits for a person.

First, make sure the tests mean something

A component test can execute a branch without checking what that branch does. Mounting a component and asserting that it exists, for example, tells us little about whether its computed values are correct.

We use Stryker to investigate those gaps before changing the component. Stryker makes small edits to the source (flips a boolean, swaps > for >=, deletes a statement) and reruns the tests. A surviving mutant means the tests passed despite that edit. We inspect it to decide whether an assertion is missing or the edit makes no observable difference.

A full Stryker run across the dashboard is too slow to repeat per component, so the coverage skill scopes the mutation pass to one file and Vitest to its spec. This is the configuration example from that skill; the line range selects the script section of the component:

// stryker.config.json, temporary
"mutate": ["src/components/form-output/CancelPaidChoiceModal.vue:144-497"]
// vitest.config.mts, temporary
test: {
  include: ['tests/unit/components/form-output/CancelPaidChoiceModal.spec.ts'],
}

The agent picks up to ten useful tests to add, commits them separately from the migration, and reverts both config edits. If the existing tests already cover the component well, the skill allows it to skip adding tests and requires an explanation in the PR.

Mutation testing helped us decide which assertions to add before recording the baseline.

Compare reactive state before and after

We wrote a Vitest plugin, vue-reactivity-snapshot, to compare recorded reactive state across the migration. It attaches to __VUE_DEVTOOLS_GLOBAL_HOOK__, the interface the Vue DevTools extension uses, and listens for component:added and component:updated. On those events it extracts state and appends it to JSON files next to the spec, grouped by component and test.

The recorder reads Options API data and computed getters through the component instance. For Composition API components it reads instance.setupState, unwrapping proxies and refs. Props are collected in both cases. Each file is a timeline. For the DataTable example, navigating to page three and then changing the query could produce this sequence, showing only the page state:

[
  { "state": { "page": 1 } },
  { "state": { "page": 3 } },
  { "state": { "page": 1 } }
]

Serialising live Vue state took more work than we expected. Reading state during extraction could trigger another hook event and re-enter the recorder. A recording flag blocks that recursion. We also had to unwrap proxies and refs at every level and skip Vue’s internal dependency-tracking objects.

Read the snapshot differences

The snapshot reporter compares the Options API and Composition API recordings for each test after the migrated component runs with REACTIVE_SNAPSHOT=1. The two styles can produce different numbers of update events, so it removes consecutive duplicate values for each reactive key and compares the remaining sequences. For the DataTable, losing the watcher that resets pagination would leave:

page
  options-api:     [1, 3, 1]
  composition-api: [1, 3]

The reporter compares keys independently, so it does not preserve the ordering of changes across different keys. It checks emitted-event names, but not their payloads, counts or ordering. Those still need test assertions.

The recordings also need deterministic inputs. The test setup seeds faker with faker.seed(0), pins the clock, and aliases faker to a single instance across packages. The reporter treats dates within 250 ms as equal to absorb drift from advancing fake timers. That tolerance means it cannot catch timing differences within that window.

Easiest first, on purpose

The migration backlog is a Markdown checklist, ordered from easiest to hardest. An agent ranked the components using props, emits, Vuex usage, injected plugins, mixins and component refs. We reviewed and corrected the order by hand.

We scheduled mixins ahead of their consumers so their composable replacements would be available first. The form builder, schedule builder, data tables and page builder went near the end. A successful migration checks off its backlog entry in the same commit as the code change.

Running it on a schedule

The scheduled migration task has explicit stopping conditions. If a branch for the component already exists, it aborts to avoid a competing migration. If unexplained snapshot differences survive three fix cycles, it reverts the migration, opens a tests-only PR and leaves the backlog entry unchecked for a person to investigate.

After opening the PR, the task polls CI for up to 45 minutes per push. It can attempt three rounds of fixes for failing checks. If those fail, it leaves a comment describing what it tried, applies a needs-human label and stops.

Once CI passes, a second skill scores the PR on size, spread across projects, test changes and risk signals in the diff. It posts its reasoning as a comment. A score of 90 or higher permits a squash merge; a lower score leaves the PR open for human review.

The one that got through

The production regression affected the poster, slides, figures and video tabs on presentation pages. Each tab reads its media from a parent through inject('presentationMedia'). Our parent used @Provide to supply a ref. The class component accessed the unwrapped value through this, so its code read this.presentationMedia.poster.

The migrated code kept that access pattern:

const presentationMedia = inject<PresentationMedia>('presentationMedia')!;
const posterUrl = computed<string | undefined>(() => presentationMedia.poster?.value);

The provider still supplied a ref, so presentationMedia.poster was undefined and the media tabs rendered empty for participants. The June 15 fix changed the injected type and added .value across eight pages in the event website and virtual platform:

const presentationMedia = inject<Ref<PresentationMedia>>('presentationMedia')!;
const posterUrl = computed<string | undefined>(() => presentationMedia.value.poster?.value);

The unit tests provided a plain object, { poster: { value: url } }, which matched what the class component appeared to consume. With that fixture, both versions produced the same recorded state. The tests asserted on the poster URL, but the input shape was wrong. The TypeScript annotation on inject() did not check the provider’s runtime value either.

We changed those eight specs to supply computed refs. The fixture change for the poster test was:

// Before
.withProvide({ presentationMedia: { poster: { value: url } } })

// After
.withProvide({ presentationMedia: computed(() => ({ poster: { value: url } })) })

Since it was a user-facing bug, we added a rule to the migration skill: whenever a component injects a value, open the provider and check what it actually supplies before writing the fixture.

What changed after the migration

The migration was intended to leave the experience for organizers and participants unchanged. The empty media tabs were the exception. We continued shipping features while moving the components to Vue’s standard APIs and adding assertions around the behaviour we needed to preserve.

vue-reactivity-snapshot lives in our common testing package. We are extracting it and will open source it so other teams can try the recorder and inspect its limits.

If this is the kind of work you enjoy, we are hiring developers.