Beta dependency tracking executes watchers with inconsistent values
See original GitHub issueVersion
2.5.17-beta.0
Reproduction link
https://jsfiddle.net/w5d9gqmo/3/
Steps to reproduce
In provided example:
- open console
- hit increment
- observe computed value evaluations in console
In vue terms:
- create dependency structure with many paths to the same watcher (A->B, B->C, A->C)
- Notify leaf watcher about change (data change)
- Observe too eager synchronous updates of watchers on every intermediate change
What is expected?
All updated computed values are executed once and with correct dependant values.
twoCounts evaluated to: {count: 2, countPlusOne: 3}
What is actually happening?
Computed values are executed multiple times with all intermediate dependency values.
twoCounts evaluated to: {count: 2, countPlusOne: 2}
twoCounts evaluated to: {count: 2, countPlusOne: 3}
The issue is clearly caused by this commit: https://github.com/vuejs/vue/commit/653aac2c57d15f0e93a2c1cc7e6fad156658df19
I did some debugging and found the cause.
Dependencies are notified synchronously about the watcher update in the notify
loop, but the updating watchers might be accessed too early with stale value and no information about necessary recomputation.
I attempted a fix by splitting Dep.notify
into two phases with separate loops for dirtify
and update
. It worked for simple situations where there is triangle of dependencies (countPlusOne
in the example), but it stops working when the graph is any more complicated (paths are larger than 1). This is illustrated by countPlusTwo
computed in the example.
Possible full fix would involve either traversing the full dependency graph to dirtify
all deep dependants first, or collecting the the array of dependants of my dependants
like in previous implementation.
Issue Analytics
- State:
- Created 5 years ago
- Reactions:4
- Comments:9 (3 by maintainers)
Top GitHub Comments
@indirectlylit
This is exactly what’s happening in standard computed values 😄
Computed values are lazy watchers, which mean that on dependency change, a dirty flag is set. https://github.com/vuejs/vue/blob/0737d11a146818d975266953ee547a12a63a1a41/src/core/observer/watcher.js#L159-L172
Once a value is requested, the cached one is used or it is recomputed when dirty. https://github.com/vuejs/vue/blob/0737d11a146818d975266953ee547a12a63a1a41/src/core/instance/state.js#L244-L255
The important part is that transitive dependencies are always treated like direct dependencies. In means if a computed value A calls computed value B, all dependencies of B are copied to A. This is done like so, because when B’s dependency change and you request value of A, it has to be recomputed or it risks being outdated.
https://github.com/vuejs/vue/blob/0737d11a146818d975266953ee547a12a63a1a41/src/core/observer/watcher.js#L214-L222
Changing that behaviour to first check if B was actually changed is not that simple, as you have to keep that deferred up to the point when A is requested (or any other dependant computed value), while caring about A to not recompute if possible. I think that this is achievable, but the complexity is much bigger and in many cases it might turn out that benefits outweighs the costs, both in complexity and speed. In today’s hardware, the memory fetch is usually the slowest action you can take, so caching every trivial computation like that can actually be a serious performance hog. There is still a place for methods 😄
While that’s probably true statistically, it’s still unfortunate for situations where a computed property does some heavy calulation over and over because a previous computed property often updates, but always returns the same value.
I guess in these situations the user must do some manual caching in this computed property, or use a memoized method instead?