Skip to content

Commit 45ba06a

Browse files
committed
fix(reactivity): should not trigger map keys iteration when keys did not change
fix #877
1 parent 0dba5d4 commit 45ba06a

File tree

3 files changed

+89
-74
lines changed

3 files changed

+89
-74
lines changed

packages/reactivity/__tests__/collections/Map.spec.ts

+29
Original file line numberDiff line numberDiff line change
@@ -362,5 +362,34 @@ describe('reactivity/collections', () => {
362362
expect(map.has(key)).toBe(false)
363363
expect(map.get(key)).toBeUndefined()
364364
})
365+
366+
// #877
367+
it('should not trigger key iteration when setting existing keys', () => {
368+
const map = reactive(new Map())
369+
const spy = jest.fn()
370+
371+
effect(() => {
372+
const keys = []
373+
for (const key of map.keys()) {
374+
keys.push(key)
375+
}
376+
spy(keys)
377+
})
378+
379+
expect(spy).toHaveBeenCalledTimes(1)
380+
expect(spy.mock.calls[0][0]).toMatchObject([])
381+
382+
map.set('a', 0)
383+
expect(spy).toHaveBeenCalledTimes(2)
384+
expect(spy.mock.calls[1][0]).toMatchObject(['a'])
385+
386+
map.set('b', 0)
387+
expect(spy).toHaveBeenCalledTimes(3)
388+
expect(spy.mock.calls[2][0]).toMatchObject(['a', 'b'])
389+
390+
// keys didn't change, should not trigger
391+
map.set('b', 1)
392+
expect(spy).toHaveBeenCalledTimes(3)
393+
})
365394
})
366395
})

packages/reactivity/src/collectionHandlers.ts

+9-5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { toRaw, reactive, readonly } from './reactive'
2-
import { track, trigger, ITERATE_KEY } from './effect'
2+
import { track, trigger, ITERATE_KEY, MAP_KEY_ITERATE_KEY } from './effect'
33
import { TrackOpTypes, TriggerOpTypes } from './operations'
44
import { LOCKED } from './lock'
55
import { isObject, capitalize, hasOwn, hasChanged } from '@vue/shared'
@@ -134,12 +134,16 @@ function createForEach(isReadonly: boolean) {
134134
function createIterableMethod(method: string | symbol, isReadonly: boolean) {
135135
return function(this: IterableCollections, ...args: unknown[]) {
136136
const target = toRaw(this)
137-
const isPair =
138-
method === 'entries' ||
139-
(method === Symbol.iterator && target instanceof Map)
137+
const isMap = target instanceof Map
138+
const isPair = method === 'entries' || (method === Symbol.iterator && isMap)
139+
const isKeyOnly = method === 'keys' && isMap
140140
const innerIterator = getProto(target)[method].apply(target, args)
141141
const wrap = isReadonly ? toReadonly : toReactive
142-
track(target, TrackOpTypes.ITERATE, ITERATE_KEY)
142+
track(
143+
target,
144+
TrackOpTypes.ITERATE,
145+
isKeyOnly ? MAP_KEY_ITERATE_KEY : ITERATE_KEY
146+
)
143147
// return a wrapped iterator which returns observed versions of the
144148
// values emitted from the real iterator
145149
return {

packages/reactivity/src/effect.ts

+51-69
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { TrackOpTypes, TriggerOpTypes } from './operations'
2-
import { EMPTY_OBJ, extend, isArray } from '@vue/shared'
2+
import { EMPTY_OBJ, isArray } from '@vue/shared'
33

44
// The main WeakMap that stores {target -> key -> dep} connections.
55
// Conceptually, it's easier to think of a dependency as a Dep class
@@ -43,7 +43,8 @@ export interface DebuggerEventExtraInfo {
4343
const effectStack: ReactiveEffect[] = []
4444
export let activeEffect: ReactiveEffect | undefined
4545

46-
export const ITERATE_KEY = Symbol('iterate')
46+
export const ITERATE_KEY = Symbol(__DEV__ ? 'iterate' : '')
47+
export const MAP_KEY_ITERATE_KEY = Symbol(__DEV__ ? 'Map key iterate' : '')
4748

4849
export function isEffect(fn: any): fn is ReactiveEffect {
4950
return fn && fn._isEffect === true
@@ -174,97 +175,78 @@ export function trigger(
174175
// never been tracked
175176
return
176177
}
178+
177179
const effects = new Set<ReactiveEffect>()
178180
const computedRunners = new Set<ReactiveEffect>()
181+
const add = (effectsToAdd: Set<ReactiveEffect> | undefined) => {
182+
if (effectsToAdd !== void 0) {
183+
effectsToAdd.forEach(effect => {
184+
if (effect !== activeEffect || !shouldTrack) {
185+
if (effect.options.computed) {
186+
computedRunners.add(effect)
187+
} else {
188+
effects.add(effect)
189+
}
190+
} else {
191+
// the effect mutated its own dependency during its execution.
192+
// this can be caused by operations like foo.value++
193+
// do not trigger or we end in an infinite loop
194+
}
195+
})
196+
}
197+
}
198+
179199
if (type === TriggerOpTypes.CLEAR) {
180200
// collection being cleared
181201
// trigger all effects for target
182-
depsMap.forEach(dep => {
183-
addRunners(effects, computedRunners, dep)
184-
})
202+
depsMap.forEach(add)
185203
} else if (key === 'length' && isArray(target)) {
186204
depsMap.forEach((dep, key) => {
187205
if (key === 'length' || key >= (newValue as number)) {
188-
addRunners(effects, computedRunners, dep)
206+
add(dep)
189207
}
190208
})
191209
} else {
192210
// schedule runs for SET | ADD | DELETE
193211
if (key !== void 0) {
194-
addRunners(effects, computedRunners, depsMap.get(key))
212+
add(depsMap.get(key))
195213
}
196214
// also run for iteration key on ADD | DELETE | Map.SET
197-
if (
215+
const isAddOrDelete =
198216
type === TriggerOpTypes.ADD ||
199-
(type === TriggerOpTypes.DELETE && !isArray(target)) ||
217+
(type === TriggerOpTypes.DELETE && !isArray(target))
218+
if (
219+
isAddOrDelete ||
200220
(type === TriggerOpTypes.SET && target instanceof Map)
201221
) {
202-
const iterationKey = isArray(target) ? 'length' : ITERATE_KEY
203-
addRunners(effects, computedRunners, depsMap.get(iterationKey))
222+
add(depsMap.get(isArray(target) ? 'length' : ITERATE_KEY))
223+
}
224+
if (isAddOrDelete && target instanceof Map) {
225+
add(depsMap.get(MAP_KEY_ITERATE_KEY))
204226
}
205227
}
228+
206229
const run = (effect: ReactiveEffect) => {
207-
scheduleRun(
208-
effect,
209-
target,
210-
type,
211-
key,
212-
__DEV__
213-
? {
214-
newValue,
215-
oldValue,
216-
oldTarget
217-
}
218-
: undefined
219-
)
230+
if (__DEV__ && effect.options.onTrigger) {
231+
effect.options.onTrigger({
232+
effect,
233+
target,
234+
key,
235+
type,
236+
newValue,
237+
oldValue,
238+
oldTarget
239+
})
240+
}
241+
if (effect.options.scheduler !== void 0) {
242+
effect.options.scheduler(effect)
243+
} else {
244+
effect()
245+
}
220246
}
247+
221248
// Important: computed effects must be run first so that computed getters
222249
// can be invalidated before any normal effects that depend on them are run.
223250
computedRunners.forEach(run)
224251
effects.forEach(run)
225252
}
226-
227-
function addRunners(
228-
effects: Set<ReactiveEffect>,
229-
computedRunners: Set<ReactiveEffect>,
230-
effectsToAdd: Set<ReactiveEffect> | undefined
231-
) {
232-
if (effectsToAdd !== void 0) {
233-
effectsToAdd.forEach(effect => {
234-
if (effect !== activeEffect || !shouldTrack) {
235-
if (effect.options.computed) {
236-
computedRunners.add(effect)
237-
} else {
238-
effects.add(effect)
239-
}
240-
} else {
241-
// the effect mutated its own dependency during its execution.
242-
// this can be caused by operations like foo.value++
243-
// do not trigger or we end in an infinite loop
244-
}
245-
})
246-
}
247-
}
248-
249-
function scheduleRun(
250-
effect: ReactiveEffect,
251-
target: object,
252-
type: TriggerOpTypes,
253-
key: unknown,
254-
extraInfo?: DebuggerEventExtraInfo
255-
) {
256-
if (__DEV__ && effect.options.onTrigger) {
257-
const event: DebuggerEvent = {
258-
effect,
259-
target,
260-
key,
261-
type
262-
}
263-
effect.options.onTrigger(extraInfo ? extend(event, extraInfo) : event)
264-
}
265-
if (effect.options.scheduler !== void 0) {
266-
effect.options.scheduler(effect)
267-
} else {
268-
effect()
269-
}
270-
}

0 commit comments

Comments
 (0)