]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(reactivity): improve support of getter usage in reactivity APIs (#7997)
authorEvan You <yyx990803@gmail.com>
Sun, 2 Apr 2023 02:17:51 +0000 (10:17 +0800)
committerGitHub <noreply@github.com>
Sun, 2 Apr 2023 02:17:51 +0000 (10:17 +0800)
packages/compiler-sfc/__tests__/compileScriptPropsDestructure.spec.ts
packages/compiler-sfc/src/compileScript.ts
packages/compiler-sfc/src/compileScriptPropsDestructure.ts
packages/dts-test/ref.test-d.ts
packages/reactivity/__tests__/ref.spec.ts
packages/reactivity/src/index.ts
packages/reactivity/src/ref.ts
packages/runtime-core/src/index.ts

index 346f95a5c7a8babb6f17e33366e7f637f4ef5503..8487270c265f4da8ba7a0075df1b8cb53236ad67 100644 (file)
@@ -294,7 +294,7 @@ describe('sfc props transform', () => {
       ).toThrow(`Cannot assign to destructured props`)
     })
 
-    test('should error when watching destructured prop', () => {
+    test('should error when passing destructured prop into certain methods', () => {
       expect(() =>
         compile(
           `<script setup>
@@ -303,7 +303,9 @@ describe('sfc props transform', () => {
         watch(foo, () => {})
         </script>`
         )
-      ).toThrow(`"foo" is a destructured prop and cannot be directly watched.`)
+      ).toThrow(
+        `"foo" is a destructured prop and should not be passed directly to watch().`
+      )
 
       expect(() =>
         compile(
@@ -313,7 +315,33 @@ describe('sfc props transform', () => {
         w(foo, () => {})
         </script>`
         )
-      ).toThrow(`"foo" is a destructured prop and cannot be directly watched.`)
+      ).toThrow(
+        `"foo" is a destructured prop and should not be passed directly to watch().`
+      )
+
+      expect(() =>
+        compile(
+          `<script setup>
+        import { toRef } from 'vue'
+        const { foo } = defineProps(['foo'])
+        toRef(foo)
+        </script>`
+        )
+      ).toThrow(
+        `"foo" is a destructured prop and should not be passed directly to toRef().`
+      )
+
+      expect(() =>
+        compile(
+          `<script setup>
+        import { toRef as r } from 'vue'
+        const { foo } = defineProps(['foo'])
+        r(foo)
+        </script>`
+        )
+      ).toThrow(
+        `"foo" is a destructured prop and should not be passed directly to toRef().`
+      )
     })
 
     // not comprehensive, but should help for most common cases
index de9e11d071fdcdcc1138de7af7ea9b772570ff04..ec476c4ad16c0b0bd32e8b8cbda60fba1642775b 100644 (file)
@@ -1442,7 +1442,7 @@ export function compileScript(
       startOffset,
       propsDestructuredBindings,
       error,
-      vueImportAliases.watch
+      vueImportAliases
     )
   }
 
index bc38912653e28431f32f251a7374cf93cddb4b98..4ee09070d76d306c355b0bd8e04701a2c51892ce 100644 (file)
@@ -32,7 +32,7 @@ export function transformDestructuredProps(
   offset = 0,
   knownProps: PropsDestructureBindings,
   error: (msg: string, node: Node, end?: number) => never,
-  watchMethodName = 'watch'
+  vueImportAliases: Record<string, string>
 ) {
   const rootScope: Scope = {}
   const scopeStack: Scope[] = [rootScope]
@@ -152,6 +152,19 @@ export function transformDestructuredProps(
     return false
   }
 
+  function checkUsage(node: Node, method: string, alias = method) {
+    if (isCallOf(node, alias)) {
+      const arg = unwrapTSNode(node.arguments[0])
+      if (arg.type === 'Identifier') {
+        error(
+          `"${arg.name}" is a destructured prop and should not be passed directly to ${method}(). ` +
+            `Pass a getter () => ${arg.name} instead.`,
+          arg
+        )
+      }
+    }
+  }
+
   // check root scope first
   walkScope(ast, true)
   ;(walk as any)(ast, {
@@ -169,16 +182,8 @@ export function transformDestructuredProps(
         return this.skip()
       }
 
-      if (isCallOf(node, watchMethodName)) {
-        const arg = unwrapTSNode(node.arguments[0])
-        if (arg.type === 'Identifier') {
-          error(
-            `"${arg.name}" is a destructured prop and cannot be directly watched. ` +
-              `Use a getter () => ${arg.name} instead.`,
-            arg
-          )
-        }
-      }
+      checkUsage(node, 'watch', vueImportAliases.watch)
+      checkUsage(node, 'toRef', vueImportAliases.toRef)
 
       // function scopes
       if (isFunctionType(node)) {
index dbf54de09c8821ec9657309610926e8f07126d68..bbcde45ddda05793726f39e82262c7e996074430 100644 (file)
@@ -7,10 +7,15 @@ import {
   reactive,
   proxyRefs,
   toRef,
+  toValue,
   toRefs,
   ToRefs,
   shallowReactive,
-  readonly
+  readonly,
+  MaybeRef,
+  MaybeRefOrGetter,
+  ComputedRef,
+  computed
 } from 'vue'
 import { expectType, describe } from './utils'
 
@@ -26,6 +31,8 @@ function plainType(arg: number | Ref<number>) {
 
   // ref unwrapping
   expectType<number>(unref(arg))
+  expectType<number>(toValue(arg))
+  expectType<number>(toValue(() => 123))
 
   // ref inner type should be unwrapped
   const nestedRef = ref({
@@ -203,6 +210,13 @@ expectType<Ref<string>>(p2.obj.k)
   // Should not distribute Refs over union
   expectType<Ref<number | string>>(toRef(obj, 'c'))
 
+  expectType<Ref<number>>(toRef(() => 123))
+  expectType<Ref<number | string>>(toRef(() => obj.c))
+
+  const r = toRef(() => 123)
+  // @ts-expect-error
+  r.value = 234
+
   // toRefs
   expectType<{
     a: Ref<number>
@@ -319,3 +333,58 @@ describe('reactive in shallow ref', () => {
 
   expectType<number>(x.value.a.b)
 })
+
+describe('toRef <-> toValue', () => {
+  function foo(
+    a: MaybeRef<string>,
+    b: () => string,
+    c: MaybeRefOrGetter<string>,
+    d: ComputedRef<string>
+  ) {
+    const r = toRef(a)
+    expectType<Ref<string>>(r)
+    // writable
+    r.value = 'foo'
+
+    const rb = toRef(b)
+    expectType<Readonly<Ref<string>>>(rb)
+    // @ts-expect-error ref created from getter should be readonly
+    rb.value = 'foo'
+
+    const rc = toRef(c)
+    expectType<Readonly<Ref<string> | Ref<string>>>(rc)
+    // @ts-expect-error ref created from MaybeReadonlyRef should be readonly
+    rc.value = 'foo'
+
+    const rd = toRef(d)
+    expectType<ComputedRef<string>>(rd)
+    // @ts-expect-error ref created from computed ref should be readonly
+    rd.value = 'foo'
+
+    expectType<string>(toValue(a))
+    expectType<string>(toValue(b))
+    expectType<string>(toValue(c))
+    expectType<string>(toValue(d))
+
+    return {
+      r: toValue(r),
+      rb: toValue(rb),
+      rc: toValue(rc),
+      rd: toValue(rd)
+    }
+  }
+
+  expectType<{
+    r: string
+    rb: string
+    rc: string
+    rd: string
+  }>(
+    foo(
+      'foo',
+      () => 'bar',
+      ref('baz'),
+      computed(() => 'hi')
+    )
+  )
+})
index 646cc6e6791bebf88424734452ddd08aa2b9fa25..718b2bc61b8f3d8d6301bdcc11a3a5632f209f3c 100644 (file)
@@ -11,7 +11,12 @@ import {
 } from '../src/index'
 import { computed } from '@vue/runtime-dom'
 import { shallowRef, unref, customRef, triggerRef } from '../src/ref'
-import { isShallow, readonly, shallowReactive } from '../src/reactive'
+import {
+  isReadonly,
+  isShallow,
+  readonly,
+  shallowReactive
+} from '../src/reactive'
 
 describe('reactivity/ref', () => {
   it('should hold a value', () => {
@@ -275,6 +280,15 @@ describe('reactivity/ref', () => {
     expect(toRef(r, 'x')).toBe(r.x)
   })
 
+  test('toRef on array', () => {
+    const a = reactive(['a', 'b'])
+    const r = toRef(a, 1)
+    expect(r.value).toBe('b')
+    r.value = 'c'
+    expect(r.value).toBe('c')
+    expect(a[1]).toBe('c')
+  })
+
   test('toRef default value', () => {
     const a: { x: number | undefined } = { x: undefined }
     const x = toRef(a, 'x', 1)
@@ -287,6 +301,17 @@ describe('reactivity/ref', () => {
     expect(x.value).toBe(1)
   })
 
+  test('toRef getter', () => {
+    const x = toRef(() => 1)
+    expect(x.value).toBe(1)
+    expect(isRef(x)).toBe(true)
+    expect(unref(x)).toBe(1)
+    //@ts-expect-error
+    expect(() => (x.value = 123)).toThrow()
+
+    expect(isReadonly(x)).toBe(true)
+  })
+
   test('toRefs', () => {
     const a = reactive({
       x: 1,
index 60707febef42ad3e4d81ae8cf6a9b3007900b0f7..ee4da5b19357a187504adc7cd47d49cbfedb1446 100644 (file)
@@ -3,12 +3,15 @@ export {
   shallowRef,
   isRef,
   toRef,
+  toValue,
   toRefs,
   unref,
   proxyRefs,
   customRef,
   triggerRef,
   type Ref,
+  type MaybeRef,
+  type MaybeRefOrGetter,
   type ToRef,
   type ToRefs,
   type UnwrapRef,
index 85a19802d4fe8f2a9b7c6bcbc535eaf579a0e068..5dd31a9f8cad79eb50e987863e3547c00f4e9a22 100644 (file)
@@ -6,7 +6,7 @@ import {
   triggerEffects
 } from './effect'
 import { TrackOpTypes, TriggerOpTypes } from './operations'
-import { isArray, hasChanged, IfAny } from '@vue/shared'
+import { isArray, hasChanged, IfAny, isFunction, isObject } from '@vue/shared'
 import {
   isProxy,
   toRaw,
@@ -87,9 +87,7 @@ export function isRef(r: any): r is Ref {
  * @param value - The object to wrap in the ref.
  * @see {@link https://vuejs.org/api/reactivity-core.html#ref}
  */
-export function ref<T extends object>(
-  value: T
-): [T] extends [Ref] ? T : Ref<UnwrapRef<T>>
+export function ref<T extends Ref>(value: T): T
 export function ref<T>(value: T): Ref<UnwrapRef<T>>
 export function ref<T = any>(): Ref<T | undefined>
 export function ref(value?: unknown) {
@@ -191,6 +189,9 @@ export function triggerRef(ref: Ref) {
   triggerRefValue(ref, __DEV__ ? ref.value : void 0)
 }
 
+export type MaybeRef<T = any> = T | Ref<T>
+export type MaybeRefOrGetter<T = any> = MaybeRef<T> | (() => T)
+
 /**
  * Returns the inner value if the argument is a ref, otherwise return the
  * argument itself. This is a sugar function for
@@ -207,10 +208,30 @@ export function triggerRef(ref: Ref) {
  * @param ref - Ref or plain value to be converted into the plain value.
  * @see {@link https://vuejs.org/api/reactivity-utilities.html#unref}
  */
-export function unref<T>(ref: T | Ref<T>): T {
+export function unref<T>(ref: MaybeRef<T>): T {
   return isRef(ref) ? (ref.value as any) : ref
 }
 
+/**
+ * Normalizes values / refs / getters to values.
+ * This is similar to {@link unref()}, except that it also normalizes getters.
+ * If the argument is a getter, it will be invoked and its return value will
+ * be returned.
+ *
+ * @example
+ * ```js
+ * toValue(1) // 1
+ * toValue(ref(1)) // 1
+ * toValue(() => 1) // 1
+ * ```
+ *
+ * @param source - A getter, an existing ref, or a non-function value.
+ * @see {@link https://vuejs.org/api/reactivity-utilities.html#tovalue}
+ */
+export function toValue<T>(source: MaybeRefOrGetter<T>): T {
+  return isFunction(source) ? source() : unref(source)
+}
+
 const shallowUnwrapHandlers: ProxyHandler<any> = {
   get: (target, key, receiver) => unref(Reflect.get(target, key, receiver)),
   set: (target, key, value, receiver) => {
@@ -305,7 +326,7 @@ export function toRefs<T extends object>(object: T): ToRefs<T> {
   }
   const ret: any = isArray(object) ? new Array(object.length) : {}
   for (const key in object) {
-    ret[key] = toRef(object, key)
+    ret[key] = propertyToRef(object, key)
   }
   return ret
 }
@@ -333,12 +354,36 @@ class ObjectRefImpl<T extends object, K extends keyof T> {
   }
 }
 
+class GetterRefImpl<T> {
+  public readonly __v_isRef = true
+  public readonly __v_isReadonly = true
+  constructor(private readonly _getter: () => T) {}
+  get value() {
+    return this._getter()
+  }
+}
+
 export type ToRef<T> = IfAny<T, Ref<T>, [T] extends [Ref] ? T : Ref<T>>
 
 /**
- * Can be used to create a ref for a property on a source reactive object. The
- * created ref is synced with its source property: mutating the source property
- * will update the ref, and vice-versa.
+ * Used to normalize values / refs / getters into refs.
+ *
+ * @example
+ * ```js
+ * // returns existing refs as-is
+ * toRef(existingRef)
+ *
+ * // creates a ref that calls the getter on .value access
+ * toRef(() => props.foo)
+ *
+ * // creates normal refs from non-function values
+ * // equivalent to ref(1)
+ * toRef(1)
+ * ```
+ *
+ * Can also be used to create a ref for a property on a source reactive object.
+ * The created ref is synced with its source property: mutating the source
+ * property will update the ref, and vice-versa.
  *
  * @example
  * ```js
@@ -358,10 +403,18 @@ export type ToRef<T> = IfAny<T, Ref<T>, [T] extends [Ref] ? T : Ref<T>>
  * console.log(fooRef.value) // 3
  * ```
  *
- * @param object - The reactive object containing the desired property.
- * @param key - Name of the property in the reactive object.
+ * @param source - A getter, an existing ref, a non-function value, or a
+ *                 reactive object to create a property ref from.
+ * @param [key] - (optional) Name of the property in the reactive object.
  * @see {@link https://vuejs.org/api/reactivity-utilities.html#toref}
  */
+export function toRef<T>(
+  value: T
+): T extends () => infer R
+  ? Readonly<Ref<R>>
+  : T extends Ref
+  ? T
+  : Ref<UnwrapRef<T>>
 export function toRef<T extends object, K extends keyof T>(
   object: T,
   key: K
@@ -371,15 +424,31 @@ export function toRef<T extends object, K extends keyof T>(
   key: K,
   defaultValue: T[K]
 ): ToRef<Exclude<T[K], undefined>>
-export function toRef<T extends object, K extends keyof T>(
-  object: T,
-  key: K,
-  defaultValue?: T[K]
-): ToRef<T[K]> {
-  const val = object[key]
+export function toRef(
+  source: Record<string, any> | MaybeRef,
+  key?: string,
+  defaultValue?: unknown
+): Ref {
+  if (isRef(source)) {
+    return source
+  } else if (isFunction(source)) {
+    return new GetterRefImpl(source as () => unknown) as any
+  } else if (isObject(source) && arguments.length > 1) {
+    return propertyToRef(source, key!, defaultValue)
+  } else {
+    return ref(source)
+  }
+}
+
+function propertyToRef(source: object, key: string, defaultValue?: unknown) {
+  const val = (source as any)[key]
   return isRef(val)
     ? val
-    : (new ObjectRefImpl(object, key, defaultValue) as any)
+    : (new ObjectRefImpl(
+        source as Record<string, any>,
+        key,
+        defaultValue
+      ) as any)
 }
 
 // corner case when use narrows type
index 06f9a2affd4f820daa94f099de65efd92b5a932a..936d6ca3565275ce36f234c55c96943d7ef45741 100644 (file)
@@ -11,6 +11,7 @@ export {
   proxyRefs,
   isRef,
   toRef,
+  toValue,
   toRefs,
   isProxy,
   isReactive,
@@ -152,6 +153,8 @@ declare module '@vue/reactivity' {
 export { TrackOpTypes, TriggerOpTypes } from '@vue/reactivity'
 export type {
   Ref,
+  MaybeRef,
+  MaybeRefOrGetter,
   ToRef,
   ToRefs,
   UnwrapRef,