u/gissen_dev

261 green tests, and undo/redo was completely broken in the real app — three Vue gotchas that don't show up in CI
▲ 0 r/vuejs

261 green tests, and undo/redo was completely broken in the real app — three Vue gotchas that don't show up in CI

My test suite was fully green (261 tests) while a core feature was flat-out broken in a real Nuxt app. It came down to three separate Vue 3 behaviours, and none of them were exotic. And all three are the kind of thing that passes review, passes CI, and quietly does nothing.

1. onUnmounted + template refs: cleanup that never ran, not once

This looks fine and is a silent no-op:

const el = ref(null)

onMounted(() => {
  el.value.addEventListener('keydown', onKeydown)
})

onUnmounted(() => {
  el.value?.removeEventListener('keydown', onKeydown)  // el.value is null here
})

Vue resets template refs to null before onUnmounted runs. So el.value?. short-circuits, removeEventListener is never called, and every instance leaks its listener onto a detached element.

The optional chaining is what makes it invisible. I mean no error, no warning, nothing to notice. In my case this had been broken since the day it was written, and it affected the shortcuts that had "worked fine" for weeks, not just the new ones.

Fix: capture the element in the onMounted closure and register the teardown there.

onMounted(() => {
  const node = el.value
  node.addEventListener('keydown', onKeydown)
  onUnmounted(() => node.removeEventListener('keydown', onKeydown))
})

2. defineModel doesn't apply a local set when the parent binds v-model

This is the one that cost me the most time. When the parent binds v-model, defineModel only emits — it does not update the local value. The new value comes back through the prop on the next tick.

That deferred round-trip broke my document sync. I had a watcher distinguishing "the user edited something in here" from "the host app replaced the whole document," using a flag set during internal writes. But the write and the echo are not in the same tick: by the time the value arrived back through the prop, the flag had already been cleared, so every single internal commit was classified as an external document replacement — which reset undo history. Undo was permanently dead in the real app, and every test was green.

The reason CI never caught it was that no test bound a live parent v-model listener. Without a parent actually listening, defineModel falls back to updating locally, the deferred path never runs, and the bug does not exist in the test environment.

Two things fixed it. First, stop using a flag and compare object identity of the last document written out because identity survives across ticks, a boolean doesn't. Second, and this cost me a second round of red tests: use toRaw when comparing. A deep ref hands your watcher a proxy, not the object you stored, so identity comparison fails until you unwrap it.

Then I added an end-to-end test that mounts the component with a real v-model round-trip. The fact that it fails with the old code is exactly why I trust it.

3. structuredClone throws on Vue proxies, but only from the second mutation on

The intermittency is what makes this one nasty.

// commit
history.push({ ...data.value })   // members read through the reactive proxy

Spreading a reactive object gives you a plain top-level object whose members are still proxy-wrapped. First mutation clones fine. From the second one on, structuredClone hits a proxy member and throws.

toRaw only unwraps one level, so calling it on the result doesn't save you. Unwrap before the spread:

history.push({ ...toRaw(data.value) })

All three only broke in situations my tests didn’t cover: a real unmount, a real parent listener, and a second mutation. The tests were green, but I just wasn’t testing the right thing.

These came out of building Gissen, an open-source headless visual editor for Vue 3.

u/gissen_dev — 8 days ago
▲ 65 r/vuejs

There's no open-source visual page builder for Vue like Puck, so I started building one

Puck is the best open-source visual editor for React — you register your own components, users drag them onto a canvas, you get JSON back. The maintainers have said they're not porting it to Vue, and I couldn't find a Vue equivalent at that quality. So the options for devs were a proprietary SaaS builder or building one. I started building one and it's called Gissen, MIT-licensed.

The idea: you register your existing Vue components with a typed config (fields, types, defaults), then drag them onto a canvas. The output is plain JSON you render back into real Vue components. No iframe, because components mount directly into the same DOM, so your scoped styles just work and the canvas looks exactly like production.

What works right now: the editor canvas, drag from a palette, reorder, nesting into containers, select/delete, the typed config, and JSON output via v-model:data.

What doesn't yet: editing prop values (the properties panel is still a stub), the MCP server for agents (skeleton only), and a production render helper to turn the JSON back into Vue outside the editor. It's pre-alpha, please don't ship it.

The part that ate the most time was syncing SortableJS with a reactive Vue store: index translation between Sortable's final position and the store's insert index, reverting the DOM before mutating state so Vue patches from a clean baseline, and preventing a container from being dropped into its own descendant. I wrote up those gotchas in more detail if anyone's interested (link below).

Repo: github.com/gissen-dev/gissen
npm: npm install gissen
The SortableJS write-up are linked in the README.

Question for the sub: for those who've used Puck or built something similar — what would actually make a Vue page builder useful enough for you to reach for it instead of hand-coding? Trying to prioritize what comes after the properties panel.

u/gissen_dev — 2 months ago