Ref History Adventures — Part 1: Ignorable Watch
by Matias Capeletto
Releasing Layoutit Grid as open source has been transformational for our company. The reception that opening the project had outside and inside the company was heartwarming. We are now encouraging and supporting ourselves to contribute and participate more in open source projects. In this post, first published in patak.dev I share a few lessons learned while working to upstream a few ideas we got while developing undo support for Layoutit Grid to vueuse’s useRefHistory.
Ref History Adventures
Part I → Ignorable Watch
Part II → History and Persistence
Part III → Mark Raw Optimization
Vueuse’s useRefHistory
lets you watch a ref and keep track of its history, providing undo and redo functionality. It uses flush 'pre'
by default to watch the source ref. Its semantics are aligned with the standard watch
, creating history points that bundle all the changes done to the source value during the same “tick”. The use of flush 'sync'
is discouraged in the Vue docs. Being able to buffers invalidated effects is important for performance, but also to avoid breaking invariants when generating spurious history points in updates that require several operations. One big caveat for example is that when deeply sync watching an array, a single splice call generates up to three triggers.
Let’s look at a simplified useUndo
composable that only allows undoing of ref changes to understand how we were able to do it and how ignorableWatch
was distilled so we can use the same technique when building other composables. The main difficulty to implement ref history is how to update the source when undoing without triggering the internal watch and re-adding this state to the history. If we only need to support sync flushing, it can be implemented in a straight forward way using a guard.
function useSyncUndo(source, options) {
const history = ref([source.value])
const undoing = ref(false)
const _commit = () => {
history.value.unshift(source.value)
}
const _undo = () => {
if (history.value.length > 1) {
history.value.shift()
source.value = history.value[0]
}
}
const stop = watch(
source,
() => {
if (!undoing.value) {
_commit()
}
},
{ ...options, flush: 'sync' }
)
function undo() {
undoing.value = true
_undo()
undoing.value = false
}
return { undo, history, stop }
}
This technique doesn’t work with flush pre and post because the sync change to the source in the current “tick” triggers the watch but it is flushed together with other effects after the tick is over. So we need a way to flag certain updates to the source and ignore them after the watch is called. We can achieve this using a double watch.
function ignorableWatch(source,cb,options) {
const ignoreCount = ref(0)
const syncCount = ref(0)
const syncStop = watch(
source,
() => {
syncCount.value++
},
{ ...options, flush: 'sync' },
)
const stop = watch( source,
(...args) => {
const ignore = ignoreCount.value > 0 &&
ignoreCount.value === syncCount.value
ignoreCount.value = 0
syncCount.value = 0
if (!ignore) {
cb(...args)
}
},
options
)
const ignoreUpdates = (updater) => {
const prev = syncCount.value
updater()
const changes = syncCount.value - prev
// Add sync changes done in updater
ignoreCount.value += changes
}
const ignorePrevAsyncUpdates = () => {
// All sync changes til are ignored
ignoreCount.value = syncCount.value
}
return {
ignoreUpdates,
ignorePreAsyncUpdates,
stop: () => {
syncStop()
stop()
}
}
}
The syncCount is incremented in sync with every change to the source ref value using a watch with flush 'sync'
. This lets us know how many times the ref has been modified in this “tick”. When calling ignoreUpdates
, all sync changes to the source in the updater function will be counted and added to the ignoreCount
. When the second watch flushes at the end of the tick, we know if there were more changes done to the source than the ones that were ignored so we can filter out the triggered effect if all the changes in the “tick” were marked to be ignored.
Vueuse’s ignorableWatch
follows this idea supporting all flush modes. It is implemented using the double watch scheme for flush 'pre'
and 'post'
, a sync watch allows us to count changes and ignore the triggered effect if all operations were flagged to be ignored in the current “tick”. For 'sync'
, a single watch with a guard is used to implement ignoreUpdates
and ignorePrevAsyncUpdates
is a no-op provided so users can write generic code that does not depend on the flush
mode.
There is a related utility in vueuse called pausableWatch that exposes two methods pause()
and resume()
allowing to ignore the watch while it is paused. ignorableWatch
is different from it because in pausableWatch
effects are ignored if the watch is paused at flush time.
const source = ref(0)
const { pause, resume } = pausableWatch(
source,
() => {
console.log(source.value)
}
)
pause()
source.value = 1 // paused while changed
resume()
await nextTick()
// but it is not paused at flush time,
// so it still logs 1
When we use ignorableWatch
, the effects are ignored when they are triggered.
const source = ref(0)
const { ignoreUpdates } = ignorableWatch(
source,
() => {
console.log(source.value)
}
)
ignoreUpdates( () => {
source.value = 1
})
await nextTick()
// nothing... the watch ignored the change
Now that we have ignorableWatch
in our tool belt, we can build a generic version of the useUndo
composable that supports all flushing modes. We leave _commit
and _undo
untouched so we can focus on how to modify the watched source without triggering when undoing.
function useUndo(source, options) {
const history = ref([source.value])
const _commit = () => {
history.value.unshift(source.value)
}
const _undo = () => {
if (history.value.length > 1) {
history.value.shift()
source.value = history.value[0]
}
}
const {
ignoreUpdates,
ignorePrevAsyncUpdates,
stop
} = ignorableWatch(source,_commit,options)
const undo = () => {
ignorePrevAsyncUpdates()
ignoreUpdates(_undo)
}
return { undo, history, stop }
}
You can check the implementation of useRefHistory
to see how ignorableWatch
is used in the lib. In it, other useful features to deal with ref history are provided.
I learned about the importance of flush 'pre'
while developing undo support for Layoutit Grid. I was later able to upstream some of this experience to vueuse, and ignorableWatch
was spawned organically as part of this process. This is a good example of how working in Apps using Vue Composition API facilitates the discovery and sharing of reusable pieces.