Skip to content

Commit 775a7c2

Browse files
committed
refactor: preserve refs in reactive arrays
BREAKING CHANGE: reactive arrays no longer unwraps contained refs When reactive arrays contain refs, especially a mix of refs and plain values, Array prototype methods will fail to function properly - e.g. sort() or reverse() will overwrite the ref's value instead of moving it (see #737). Ensuring correct behavior for all possible Array methods while retaining the ref unwrapping behavior is exceedinly complicated; In addition, even if Vue handles the built-in methods internally, it would still break when the user attempts to use a 3rd party utility functioon (e.g. lodash) on a reactive array containing refs. After this commit, similar to other collection types like Map and Set, Arrays will no longer automatically unwrap contained refs. The usage of mixed refs and plain values in Arrays should be rare in practice. In cases where this is necessary, the user can create a computed property that performs the unwrapping.
1 parent 627b9df commit 775a7c2

File tree

6 files changed

+137
-93
lines changed

6 files changed

+137
-93
lines changed

packages/reactivity/__tests__/reactive.spec.ts

-64
Original file line numberDiff line numberDiff line change
@@ -26,51 +26,6 @@ describe('reactivity/reactive', () => {
2626
expect(Object.keys(observed)).toEqual(['foo'])
2727
})
2828

29-
test('Array', () => {
30-
const original = [{ foo: 1 }]
31-
const observed = reactive(original)
32-
expect(observed).not.toBe(original)
33-
expect(isReactive(observed)).toBe(true)
34-
expect(isReactive(original)).toBe(false)
35-
expect(isReactive(observed[0])).toBe(true)
36-
// get
37-
expect(observed[0].foo).toBe(1)
38-
// has
39-
expect(0 in observed).toBe(true)
40-
// ownKeys
41-
expect(Object.keys(observed)).toEqual(['0'])
42-
})
43-
44-
test('cloned reactive Array should point to observed values', () => {
45-
const original = [{ foo: 1 }]
46-
const observed = reactive(original)
47-
const clone = observed.slice()
48-
expect(isReactive(clone[0])).toBe(true)
49-
expect(clone[0]).not.toBe(original[0])
50-
expect(clone[0]).toBe(observed[0])
51-
})
52-
53-
test('Array identity methods should work with raw values', () => {
54-
const raw = {}
55-
const arr = reactive([{}, {}])
56-
arr.push(raw)
57-
expect(arr.indexOf(raw)).toBe(2)
58-
expect(arr.indexOf(raw, 3)).toBe(-1)
59-
expect(arr.includes(raw)).toBe(true)
60-
expect(arr.includes(raw, 3)).toBe(false)
61-
expect(arr.lastIndexOf(raw)).toBe(2)
62-
expect(arr.lastIndexOf(raw, 1)).toBe(-1)
63-
64-
// should work also for the observed version
65-
const observed = arr[2]
66-
expect(arr.indexOf(observed)).toBe(2)
67-
expect(arr.indexOf(observed, 3)).toBe(-1)
68-
expect(arr.includes(observed)).toBe(true)
69-
expect(arr.includes(observed, 3)).toBe(false)
70-
expect(arr.lastIndexOf(observed)).toBe(2)
71-
expect(arr.lastIndexOf(observed, 1)).toBe(-1)
72-
})
73-
7429
test('nested reactives', () => {
7530
const original = {
7631
nested: {
@@ -97,25 +52,6 @@ describe('reactivity/reactive', () => {
9752
expect('foo' in original).toBe(false)
9853
})
9954

100-
test('observed value should proxy mutations to original (Array)', () => {
101-
const original: any[] = [{ foo: 1 }, { bar: 2 }]
102-
const observed = reactive(original)
103-
// set
104-
const value = { baz: 3 }
105-
const reactiveValue = reactive(value)
106-
observed[0] = value
107-
expect(observed[0]).toBe(reactiveValue)
108-
expect(original[0]).toBe(value)
109-
// delete
110-
delete observed[0]
111-
expect(observed[0]).toBeUndefined()
112-
expect(original[0]).toBeUndefined()
113-
// mutating methods
114-
observed.push(value)
115-
expect(observed[2]).toBe(reactiveValue)
116-
expect(original[2]).toBe(value)
117-
})
118-
11955
test('setting a property with an unobserved value should wrap with reactive', () => {
12056
const observed = reactive<{ foo?: object }>({})
12157
const raw = {}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { reactive, isReactive, toRaw } from '../src/reactive'
2+
import { ref, isRef } from '../src/ref'
3+
import { effect } from '../src/effect'
4+
5+
describe('reactivity/reactive/Array', () => {
6+
test('should make Array reactive', () => {
7+
const original = [{ foo: 1 }]
8+
const observed = reactive(original)
9+
expect(observed).not.toBe(original)
10+
expect(isReactive(observed)).toBe(true)
11+
expect(isReactive(original)).toBe(false)
12+
expect(isReactive(observed[0])).toBe(true)
13+
// get
14+
expect(observed[0].foo).toBe(1)
15+
// has
16+
expect(0 in observed).toBe(true)
17+
// ownKeys
18+
expect(Object.keys(observed)).toEqual(['0'])
19+
})
20+
21+
test('cloned reactive Array should point to observed values', () => {
22+
const original = [{ foo: 1 }]
23+
const observed = reactive(original)
24+
const clone = observed.slice()
25+
expect(isReactive(clone[0])).toBe(true)
26+
expect(clone[0]).not.toBe(original[0])
27+
expect(clone[0]).toBe(observed[0])
28+
})
29+
30+
test('observed value should proxy mutations to original (Array)', () => {
31+
const original: any[] = [{ foo: 1 }, { bar: 2 }]
32+
const observed = reactive(original)
33+
// set
34+
const value = { baz: 3 }
35+
const reactiveValue = reactive(value)
36+
observed[0] = value
37+
expect(observed[0]).toBe(reactiveValue)
38+
expect(original[0]).toBe(value)
39+
// delete
40+
delete observed[0]
41+
expect(observed[0]).toBeUndefined()
42+
expect(original[0]).toBeUndefined()
43+
// mutating methods
44+
observed.push(value)
45+
expect(observed[2]).toBe(reactiveValue)
46+
expect(original[2]).toBe(value)
47+
})
48+
49+
test('Array identity methods should work with raw values', () => {
50+
const raw = {}
51+
const arr = reactive([{}, {}])
52+
arr.push(raw)
53+
expect(arr.indexOf(raw)).toBe(2)
54+
expect(arr.indexOf(raw, 3)).toBe(-1)
55+
expect(arr.includes(raw)).toBe(true)
56+
expect(arr.includes(raw, 3)).toBe(false)
57+
expect(arr.lastIndexOf(raw)).toBe(2)
58+
expect(arr.lastIndexOf(raw, 1)).toBe(-1)
59+
60+
// should work also for the observed version
61+
const observed = arr[2]
62+
expect(arr.indexOf(observed)).toBe(2)
63+
expect(arr.indexOf(observed, 3)).toBe(-1)
64+
expect(arr.includes(observed)).toBe(true)
65+
expect(arr.includes(observed, 3)).toBe(false)
66+
expect(arr.lastIndexOf(observed)).toBe(2)
67+
expect(arr.lastIndexOf(observed, 1)).toBe(-1)
68+
})
69+
70+
test('Array identity methods should be reactive', () => {
71+
const obj = {}
72+
const arr = reactive([obj, {}])
73+
74+
let index: number = -1
75+
effect(() => {
76+
index = arr.indexOf(obj)
77+
})
78+
expect(index).toBe(0)
79+
arr.reverse()
80+
expect(index).toBe(1)
81+
})
82+
83+
describe('Array methods w/ refs', () => {
84+
let original: any[]
85+
beforeEach(() => {
86+
original = reactive([1, ref(2)])
87+
})
88+
89+
// read + copy
90+
test('read only copy methods', () => {
91+
const res = original.concat([3, ref(4)])
92+
const raw = toRaw(res)
93+
expect(isRef(raw[1])).toBe(true)
94+
expect(isRef(raw[3])).toBe(true)
95+
})
96+
97+
// read + write
98+
test('read + write mutating methods', () => {
99+
const res = original.copyWithin(0, 1, 2)
100+
const raw = toRaw(res)
101+
expect(isRef(raw[0])).toBe(true)
102+
expect(isRef(raw[1])).toBe(true)
103+
})
104+
105+
test('read + indentity', () => {
106+
const ref = original[1]
107+
expect(ref).toBe(toRaw(original)[1])
108+
expect(original.indexOf(ref)).toBe(1)
109+
})
110+
})
111+
})

packages/reactivity/__tests__/ref.spec.ts

+8-14
Original file line numberDiff line numberDiff line change
@@ -49,23 +49,20 @@ describe('reactivity/ref', () => {
4949
const obj = reactive({
5050
a,
5151
b: {
52-
c: a,
53-
d: [a]
52+
c: a
5453
}
5554
})
5655

5756
let dummy1: number
5857
let dummy2: number
59-
let dummy3: number
6058

6159
effect(() => {
6260
dummy1 = obj.a
6361
dummy2 = obj.b.c
64-
dummy3 = obj.b.d[0]
6562
})
6663

6764
const assertDummiesEqualTo = (val: number) =>
68-
[dummy1, dummy2, dummy3].forEach(dummy => expect(dummy).toBe(val))
65+
[dummy1, dummy2].forEach(dummy => expect(dummy).toBe(val))
6966

7067
assertDummiesEqualTo(1)
7168
a.value++
@@ -74,8 +71,6 @@ describe('reactivity/ref', () => {
7471
assertDummiesEqualTo(3)
7572
obj.b.c++
7673
assertDummiesEqualTo(4)
77-
obj.b.d[0]++
78-
assertDummiesEqualTo(5)
7974
})
8075

8176
it('should unwrap nested ref in types', () => {
@@ -95,15 +90,14 @@ describe('reactivity/ref', () => {
9590
expect(typeof (c.value.b + 1)).toBe('number')
9691
})
9792

98-
it('should properly unwrap ref types nested inside arrays', () => {
93+
it('should NOT unwrap ref types nested inside arrays', () => {
9994
const arr = ref([1, ref(1)]).value
100-
// should unwrap to number[]
101-
arr[0]++
102-
arr[1]++
95+
;(arr[0] as number)++
96+
;(arr[1] as Ref<number>).value++
10397

10498
const arr2 = ref([1, new Map<string, any>(), ref('1')]).value
10599
const value = arr2[0]
106-
if (typeof value === 'string') {
100+
if (isRef(value)) {
107101
value + 'foo'
108102
} else if (typeof value === 'number') {
109103
value + 1
@@ -131,8 +125,8 @@ describe('reactivity/ref', () => {
131125
tupleRef.value[2].a++
132126
expect(tupleRef.value[2].a).toBe(2)
133127
expect(tupleRef.value[3]()).toBe(0)
134-
tupleRef.value[4]++
135-
expect(tupleRef.value[4]).toBe(1)
128+
tupleRef.value[4].value++
129+
expect(tupleRef.value[4].value).toBe(1)
136130
})
137131

138132
test('isRef', () => {

packages/reactivity/src/baseHandlers.ts

+13-10
Original file line numberDiff line numberDiff line change
@@ -16,20 +16,21 @@ const shallowReactiveGet = /*#__PURE__*/ createGetter(false, true)
1616
const readonlyGet = /*#__PURE__*/ createGetter(true)
1717
const shallowReadonlyGet = /*#__PURE__*/ createGetter(true, true)
1818

19-
const arrayIdentityInstrumentations: Record<string, Function> = {}
19+
const arrayInstrumentations: Record<string, Function> = {}
2020
;['includes', 'indexOf', 'lastIndexOf'].forEach(key => {
21-
arrayIdentityInstrumentations[key] = function(
22-
value: unknown,
23-
...args: any[]
24-
): any {
25-
return toRaw(this)[key](toRaw(value), ...args)
21+
arrayInstrumentations[key] = function(...args: any[]): any {
22+
const arr = toRaw(this) as any
23+
for (let i = 0, l = (this as any).length; i < l; i++) {
24+
track(arr, TrackOpTypes.GET, i + '')
25+
}
26+
return arr[key](...args.map(toRaw))
2627
}
2728
})
2829

2930
function createGetter(isReadonly = false, shallow = false) {
3031
return function get(target: object, key: string | symbol, receiver: object) {
31-
if (isArray(target) && hasOwn(arrayIdentityInstrumentations, key)) {
32-
return Reflect.get(arrayIdentityInstrumentations, key, receiver)
32+
if (isArray(target) && hasOwn(arrayInstrumentations, key)) {
33+
return Reflect.get(arrayInstrumentations, key, receiver)
3334
}
3435
const res = Reflect.get(target, key, receiver)
3536
if (isSymbol(key) && builtInSymbols.has(key)) {
@@ -40,7 +41,8 @@ function createGetter(isReadonly = false, shallow = false) {
4041
// TODO strict mode that returns a shallow-readonly version of the value
4142
return res
4243
}
43-
if (isRef(res)) {
44+
// ref unwrapping, only for Objects, not for Arrays.
45+
if (isRef(res) && !isArray(target)) {
4446
return res.value
4547
}
4648
track(target, TrackOpTypes.GET, key)
@@ -79,7 +81,7 @@ function createSetter(isReadonly = false, shallow = false) {
7981
const oldValue = (target as any)[key]
8082
if (!shallow) {
8183
value = toRaw(value)
82-
if (isRef(oldValue) && !isRef(value)) {
84+
if (!isArray(target) && isRef(oldValue) && !isRef(value)) {
8385
oldValue.value = value
8486
return true
8587
}
@@ -94,6 +96,7 @@ function createSetter(isReadonly = false, shallow = false) {
9496
if (!hadKey) {
9597
trigger(target, TriggerOpTypes.ADD, key, value)
9698
} else if (hasChanged(value, oldValue)) {
99+
debugger
97100
trigger(target, TriggerOpTypes.SET, key, value, oldValue)
98101
}
99102
}

packages/reactivity/src/reactive.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
mutableCollectionHandlers,
1010
readonlyCollectionHandlers
1111
} from './collectionHandlers'
12-
import { UnwrapRef, Ref } from './ref'
12+
import { UnwrapRef, Ref, isRef } from './ref'
1313
import { makeMap } from '@vue/shared'
1414

1515
// WeakMaps that store {raw <-> observed} pairs.
@@ -50,6 +50,9 @@ export function reactive(target: object) {
5050
if (readonlyValues.has(target)) {
5151
return readonly(target)
5252
}
53+
if (isRef(target)) {
54+
return target
55+
}
5356
return createReactiveObject(
5457
target,
5558
rawToReactive,

packages/reactivity/src/ref.ts

+1-4
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ export function isRef(r: any): r is Ref {
2929
}
3030

3131
export function ref<T>(value: T): T extends Ref ? T : Ref<T>
32-
export function ref<T>(value: T): Ref<T>
3332
export function ref<T = any>(): Ref<T>
3433
export function ref(value?: unknown) {
3534
if (isRef(value)) {
@@ -83,8 +82,6 @@ function toProxyRef<T extends object, K extends keyof T>(
8382
} as any
8483
}
8584

86-
type UnwrapArray<T> = { [P in keyof T]: UnwrapRef<T[P]> }
87-
8885
// corner case when use narrows type
8986
// Ex. type RelativePath = string & { __brand: unknown }
9087
// RelativePath extends object -> true
@@ -94,7 +91,7 @@ type BaseTypes = string | number | boolean
9491
export type UnwrapRef<T> = {
9592
cRef: T extends ComputedRef<infer V> ? UnwrapRef<V> : T
9693
ref: T extends Ref<infer V> ? UnwrapRef<V> : T
97-
array: T extends Array<infer V> ? Array<UnwrapRef<V>> & UnwrapArray<T> : T
94+
array: T
9895
object: { [K in keyof T]: UnwrapRef<T[K]> }
9996
}[T extends ComputedRef<any>
10097
? 'cRef'

0 commit comments

Comments
 (0)