Repository: vuejs/composition-api
Pull request #800 (2021-08-21 08:36:29)

feat: implement api `useSlots` and `useAttrs`


Patch:
diff --git a/src/apis/index.ts b/src/apis/index.ts
index 598dbff..515837d 100644
--- a/src/apis/index.ts
+++ b/src/apis/index.ts
@@ -25,3 +25,4 @@ export {
   getCurrentScope,
   onScopeDispose,
 } from './effectScope'
+export { useAttrs, useSlots } from './setupHelpers'
diff --git a/src/apis/setupHelpers.ts b/src/apis/setupHelpers.ts
new file mode 100644
index 0000000..4b95822
--- /dev/null
+++ b/src/apis/setupHelpers.ts
@@ -0,0 +1,18 @@
+import { getCurrentInstance, SetupContext } from '../runtimeContext'
+import { warn } from '../utils'
+
+export function useSlots(): SetupContext['slots'] {
+  return getContext().slots
+}
+
+export function useAttrs(): SetupContext['attrs'] {
+  return getContext().attrs
+}
+
+function getContext(): SetupContext {
+  const i = getCurrentInstance()!
+  if (__DEV__ && !i) {
+    warn(`useContext() called without active instance.`)
+  }
+  return i.setupContext!
+}
diff --git a/src/apis/watch.ts b/src/apis/watch.ts
index 9c165f5..83522ae 100644
--- a/src/apis/watch.ts
+++ b/src/apis/watch.ts
@@ -295,6 +295,7 @@ function createWatcher(
   }
 
   let deep = options.deep
+  let isMultiSource = false
 
   let getter: () => any
   if (isRef(source)) {
@@ -303,6 +304,7 @@ function createWatcher(
     getter = () => source
     deep = true
   } else if (isArray(source)) {
+    isMultiSource = true
     getter = () =>
       source.map((s) => {
         if (isRef(s)) {
@@ -339,6 +341,8 @@ function createWatcher(
   }
 
   const applyCb = (n: any, o: any) => {
+    if (isMultiSource && n.every((v: any, i: number) => Object.is(v, o[i])))
+      return
     // cleanup before running cb again
     runCleanup()
     return cb(n, o, registerCleanup)
diff --git a/src/component/componentOptions.ts b/src/component/componentOptions.ts
index eb8f7ed..117cd51 100644
--- a/src/component/componentOptions.ts
+++ b/src/component/componentOptions.ts
@@ -1,34 +1,9 @@
 import Vue, { VNode, ComponentOptions as Vue2ComponentOptions } from 'vue'
+import { SetupContext } from '../runtimeContext'
 import { Data } from './common'
 import { ComponentPropsOptions, ExtractPropTypes } from './componentProps'
-import { ComponentInstance, ComponentRenderProxy } from './componentProxy'
+import { ComponentRenderProxy } from './componentProxy'
 export { ComponentPropsOptions } from './componentProps'
-export interface SetupContext {
-  readonly attrs: Data
-  readonly slots: Readonly<{ [key in string]?: (...args: any[]) => VNode[] }>
-
-  /**
-   * @deprecated not available in Vue 3
-   */
-  readonly parent: ComponentInstance | null
-
-  /**
-   * @deprecated not available in Vue 3
-   */
-  readonly root: ComponentInstance
-
-  /**
-   * @deprecated not available in Vue 3
-   */
-  readonly listeners: { [key in string]?: Function }
-
-  /**
-   * @deprecated not available in Vue 3
-   */
-  readonly refs: { [key: string]: Vue | Element | Vue[] | Element[] }
-
-  emit(event: string, ...args: any[]): void
-}
 
 export type ComputedGetter<T> = (ctx?: any) => T
 export type ComputedSetter<T> = (v: T) => void
diff --git a/src/component/index.ts b/src/component/index.ts
index 8648456..c23a36f 100644
--- a/src/component/index.ts
+++ b/src/component/index.ts
@@ -2,7 +2,6 @@ export { defineComponent } from './defineComponent'
 export { defineAsyncComponent } from './defineAsyncComponent'
 export {
   SetupFunction,
-  SetupContext,
   ComputedOptions,
   MethodOptions,
   ComponentPropsOptions,
diff --git a/src/index.ts b/src/index.ts
index d7421b0..3b1632d 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -6,7 +6,11 @@ export const version = __VERSION__
 
 export * from './apis'
 export * from './component'
-export { getCurrentInstance, ComponentInternalInstance } from './runtimeContext'
+export {
+  getCurrentInstance,
+  ComponentInternalInstance,
+  SetupContext,
+} from './runtimeContext'
 
 export default Plugin
 
diff --git a/src/mixin.ts b/src/mixin.ts
index 88e4b0c..d94c9be 100644
--- a/src/mixin.ts
+++ b/src/mixin.ts
@@ -1,10 +1,5 @@
 import type { VueConstructor } from 'vue'
-import {
-  ComponentInstance,
-  SetupContext,
-  SetupFunction,
-  Data,
-} from './component'
+import { ComponentInstance, SetupFunction, Data } from './component'
 import { isRef, isReactive, toRefs, isRaw } from './reactivity'
 import {
   isPlainObject,
@@ -24,7 +19,11 @@ import {
   resolveScopedSlots,
   asVmProperty,
 } from './utils/instance'
-import { getVueConstructor } from './runtimeContext'
+import {
+  getVueConstructor,
+  SetupContext,
+  toVue3ComponentInstance,
+} from './runtimeContext'
 import { createObserver, reactive } from './reactivity/reactive'
 
 export function mixin(Vue: VueConstructor) {
@@ -53,7 +52,9 @@ export function mixin(Vue: VueConstructor) {
     if (render) {
       // keep currentInstance accessible for createElement
       $options.render = function (...args: any): any {
-        return activateCurrentInstance(vm, () => render.apply(this, args))
+        return activateCurrentInstance(toVue3ComponentInstance(vm), () =>
+          render.apply(this, args)
+        )
       }
     }
 
@@ -85,16 +86,17 @@ export function mixin(Vue: VueConstructor) {
   function initSetup(vm: ComponentInstance, props: Record<any, any> = {}) {
     const setup = vm.$options.setup!
     const ctx = createSetupContext(vm)
+    const instance = toVue3ComponentInstance(vm)
+    instance.setupContext = ctx
 
     // fake reactive for `toRefs(props)`
     def(props, '__ob__', createObserver())
 
     // resolve scopedSlots and slots to functions
-    // @ts-expect-error
     resolveScopedSlots(vm, ctx.slots)
 
     let binding: ReturnType<SetupFunction<Data, Data>> | undefined | null
-    activateCurrentInstance(vm, () => {
+    activateCurrentInstance(instance, () => {
       // make props to be fake reactive, this is for `toRefs(props)`
       binding = setup(props, ctx)
     })
@@ -105,9 +107,8 @@ export function mixin(Vue: VueConstructor) {
       const bindingFunc = binding
       // keep currentInstance accessible for createElement
       vm.$options.render = () => {
-        // @ts-expect-error
         resolveScopedSlots(vm, ctx.slots)
-        return activateCurrentInstance(vm, () => bindingFunc())
+        return activateCurrentInstance(instance, () => bindingFunc())
       }
       return
     } else if (isPlainObject(binding)) {
@@ -190,7 +191,7 @@ export function mixin(Vue: VueConstructor) {
       return true
     }
 
-    if (!isPlainObject(target) || isRaw(target)) {
+    if (!isPlainObject(target) || isRaw(target) || isRef(target)) {
       return false
     }
     return Object.keys(target).some((x) =>
@@ -228,15 +229,17 @@ export function mixin(Vue: VueConstructor) {
       })
     })
 
+    let propsProxy: any
     propsReactiveProxy.forEach((key) => {
       let srcKey = `$${key}`
       proxy(ctx, key, {
         get: () => {
-          const data = reactive({})
+          if (propsProxy) return propsProxy
+          propsProxy = reactive({})
           const source = vm[srcKey]
 
           for (const attr of Object.keys(source)) {
-            proxy(data, attr, {
+            proxy(propsProxy, attr, {
               get: () => {
                 // to ensure it always return the latest value
                 return vm[srcKey][attr]
@@ -244,7 +247,7 @@ export function mixin(Vue: VueConstructor) {
             })
           }
 
-          return data
+          return propsProxy
         },
         set() {
           __DEV__ &&
diff --git a/src/reactivity/reactive.ts b/src/reactivity/reactive.ts
index d08b7c5..f7ddc8f 100644
--- a/src/reactivity/reactive.ts
+++ b/src/reactivity/reactive.ts
@@ -135,7 +135,7 @@ export function observe<T>(obj: T): T {
 /**
  * Mock __ob__ for object recursively
  */
-function mockReactivityDeep(obj: any, seen = new Set()) {
+export function mockReactivityDeep(obj: any, seen = new Set()) {
   if (seen.has(obj)) return
 
   def(obj, '__ob__', mockObserver(obj))
diff --git a/src/reactivity/set.ts b/src/reactivity/set.ts
index dfbd40f..2270c9e 100644
--- a/src/reactivity/set.ts
+++ b/src/reactivity/set.ts
@@ -1,7 +1,14 @@
 import { AnyObject } from '../types/basic'
 import { getVueConstructor } from '../runtimeContext'
-import { isArray, isPrimitive, isUndef, isValidArrayIndex } from '../utils'
-import { defineAccessControl } from './reactive'
+import {
+  isArray,
+  isPrimitive,
+  isUndef,
+  isValidArrayIndex,
+  isObject,
+  hasOwn,
+} from '../utils'
+import { defineAccessControl, mockReactivityDeep } from './reactive'
 
 /**
  * Set a property on an object. Adds the new property, triggers change
@@ -49,6 +56,11 @@ export function set<T>(target: AnyObject, key: any, val: T): T {
   // IMPORTANT: define access control before trigger watcher
   defineAccessControl(target, key, val)
 
+  // in SSR, there is no __ob__. Mock for reactivity check
+  if (isObject(target[key]) && !hasOwn(target[key], '__ob__')) {
+    mockReactivityDeep(target[key])
+  }
+
   ob.dep.notify()
   return val
 }
diff --git a/src/runtimeContext.ts b/src/runtimeContext.ts
index 02afded..d9b5c77 100644
--- a/src/runtimeContext.ts
+++ b/src/runtimeContext.ts
@@ -134,6 +134,38 @@ export type EmitFn<
       }[Event]
     >
 
+export type Slots = Readonly<InternalSlots>
+
+export interface SetupContext<E = EmitsOptions> {
+  attrs: Data
+  slots: Slots
+  emit: EmitFn<E>
+  /**
+   * @deprecated not available in Vue 2
+   */
+  expose: (exposed?: Record<string, any>) => void
+
+  /**
+   * @deprecated not available in Vue 3
+   */
+  readonly parent: ComponentInstance | null
+
+  /**
+   * @deprecated not available in Vue 3
+   */
+  readonly root: ComponentInstance
+
+  /**
+   * @deprecated not available in Vue 3
+   */
+  readonly listeners: { [key in string]?: Function }
+
+  /**
+   * @deprecated not available in Vue 3
+   */
+  readonly refs: { [key: string]: Vue | Element | Vue[] | Element[] }
+}
+
 /**
  * We expose a subset of properties on the internal instance as they are
  * useful for advanced external libraries and tools.
@@ -179,6 +211,11 @@ export declare interface ComponentInternalInstance {
    * @internal
    */
   scope: EffectScope
+
+  /**
+   * @internal
+   */
+  setupContext: SetupContext | null
 }
 
 export function getCurrentInstance() {
@@ -190,7 +227,7 @@ const instanceMapCache = new WeakMap<
   ComponentInternalInstance
 >()
 
-function toVue3ComponentInstance(
+export function toVue3ComponentInstance(
   vm: ComponentInstance
 ): ComponentInternalInstance {
   if (instanceMapCache.has(vm)) {
diff --git a/src/utils/helper.ts b/src/utils/helper.ts
index e60c475..24b93aa 100644
--- a/src/utils/helper.ts
+++ b/src/utils/helper.ts
@@ -4,6 +4,7 @@ import {
   ComponentInternalInstance,
   getCurrentInstance,
   getVueConstructor,
+  Slot,
 } from '../runtimeContext'
 import { warn } from './utils'
 
@@ -38,8 +39,8 @@ export function isComponentInstance(obj: any) {
   return Vue && obj instanceof Vue
 }
 
-export function createSlotProxy(vm: ComponentInstance, slotName: string) {
-  return (...args: any) => {
+export function createSlotProxy(vm: ComponentInstance, slotName: string): Slot {
+  return ((...args: any) => {
     if (!vm.$scopedSlots[slotName]) {
       if (__DEV__)
         return warn(
@@ -50,7 +51,7 @@ export function createSlotProxy(vm: ComponentInstance, slotName: string) {
     }
 
     return vm.$scopedSlots[slotName]!.apply(vm, args)
-  }
+  }) as Slot
 }
 
 export function resolveSlots(
diff --git a/src/utils/instance.ts b/src/utils/instance.ts
index 7626e0d..8a294fd 100644
--- a/src/utils/instance.ts
+++ b/src/utils/instance.ts
@@ -3,7 +3,8 @@ import vmStateManager from './vmStateManager'
 import {
   setCurrentInstance,
   getCurrentInstance,
-  setCurrentVue2Instance,
+  ComponentInternalInstance,
+  InternalSlots,
 } from '../runtimeContext'
 import { Ref, isRef, isReactive } from '../apis'
 import { hasOwn, proxy, warn } from './utils'
@@ -102,7 +103,7 @@ export function updateTemplateRef(vm: ComponentInstance) {
 
 export function resolveScopedSlots(
   vm: ComponentInstance,
-  slotsProxy: { [x: string]: Function }
+  slotsProxy: InternalSlots
 ): void {
   const parentVNode = (vm.$options as any)._parentVnode
   if (!parentVNode) return
@@ -129,14 +130,14 @@ export function resolveScopedSlots(
 }
 
 export function activateCurrentInstance(
-  vm: ComponentInstance,
-  fn: (vm_: ComponentInstance) => any,
+  instance: ComponentInternalInstance,
+  fn: (instance: ComponentInternalInstance) => any,
   onError?: (err: Error) => void
 ) {
   let preVm = getCurrentInstance()
-  setCurrentVue2Instance(vm)
+  setCurrentInstance(instance)
   try {
-    return fn(vm)
+    return fn(instance)
   } catch (err) {
     if (onError) {
       onError(err)


Test patch:
diff --git a/test/apis/watch.spec.js b/test/apis/watch.spec.js
index 0bb8aeb..e01b3c3 100644
--- a/test/apis/watch.spec.js
+++ b/test/apis/watch.spec.js
@@ -5,6 +5,7 @@ const {
   watch,
   watchEffect,
   set,
+  computed,
   nextTick,
 } = require('../../src')
 const { mockWarn } = require('../helpers')
@@ -821,4 +822,28 @@ describe('api/watch', () => {
 
     expect(cb).toHaveBeenCalled()
   })
+
+  it('watching sources: ref<[]>', async () => {
+    const foo = ref([1])
+    const cb = jest.fn()
+    watch(foo, cb)
+    foo.value = foo.value.slice()
+    await nextTick()
+    expect(cb).toBeCalledTimes(1)
+  })
+
+  it('watching multiple sources: computed', async () => {
+    const number = ref(1)
+    const div2 = computed(() => {
+      return number.value > 2 ? '>2' : '<=2'
+    })
+    const div3 = computed(() => {
+      return number.value > 3 ? '>3' : '<=3'
+    })
+    const cb = jest.fn()
+    watch([div2, div3], cb)
+    number.value = 2
+    await nextTick()
+    expect(cb).toHaveBeenCalledTimes(0)
+  })
 })
diff --git a/test/setup.spec.js b/test/setup.spec.js
index ae29ec7..64f457d 100644
--- a/test/setup.spec.js
+++ b/test/setup.spec.js
@@ -1219,4 +1219,27 @@ describe('setup', () => {
       },
     }).$mount()
   })
+
+  // #794
+  it('should not trigger getter w/ object computed nested', () => {
+    const spy = jest.fn()
+    new Vue({
+      setup() {
+        new Vue({
+          setup() {
+            const person = {
+              name: computed(() => {
+                spy()
+                return 1
+              }),
+            }
+            return {
+              person,
+            }
+          },
+        })
+      },
+    })
+    expect(spy).toHaveBeenCalledTimes(0)
+  })
 })
diff --git a/test/setupContext.spec.ts b/test/setupContext.spec.ts
index 6ec978f..701bc95 100644
--- a/test/setupContext.spec.ts
+++ b/test/setupContext.spec.ts
@@ -3,6 +3,7 @@ import {
   defineComponent,
   createApp,
   ref,
+  computed,
   nextTick,
   SetupContext,
   getCurrentInstance,
@@ -225,4 +226,26 @@ describe('setupContext', () => {
       `"RangeError: Maximum call stack size exceeded"`
     ).not.toHaveBeenWarned()
   })
+
+  // #794
+  it('should not trigger getter w/ object computed nested', async () => {
+    const spy = jest.fn()
+    createApp(
+      defineComponent({
+        template: `<div/>`,
+        setup() {
+          const person = {
+            name: computed<number>(() => {
+              spy()
+              return 1
+            }),
+          }
+          return {
+            person,
+          }
+        },
+      })
+    ).mount()
+    expect(spy).toHaveBeenCalledTimes(0)
+  })
 })
diff --git a/test/ssr/ssrReactive.spec.ts b/test/ssr/ssrReactive.spec.ts
index 90ec277..432244b 100644
--- a/test/ssr/ssrReactive.spec.ts
+++ b/test/ssr/ssrReactive.spec.ts
@@ -145,4 +145,11 @@ describe('SSR Reactive', () => {
       `"RangeError: Maximum call stack size exceeded"`
     ).not.toHaveBeenWarned()
   })
+
+  it('should work on objects sets with set()', () => {
+    const state = ref<any>({})
+    set(state.value, 'a', {})
+
+    expect(isReactive(state.value.a)).toBe(true)
+  })
 })
diff --git a/test/v3/runtime-core/apiSetupHelpers.spec.ts b/test/v3/runtime-core/apiSetupHelpers.spec.ts
new file mode 100644
index 0000000..78b8ae4
--- /dev/null
+++ b/test/v3/runtime-core/apiSetupHelpers.spec.ts
@@ -0,0 +1,49 @@
+import {
+  createApp,
+  defineComponent,
+  SetupContext,
+  useAttrs,
+  useSlots,
+} from '../../../src'
+
+describe('SFC <script setup> helpers', () => {
+  // test('useSlots / useAttrs (no args)', () => {
+  //   let slots: SetupContext['slots'] | undefined
+  //   let attrs: SetupContext['attrs'] | undefined
+  //   const Comp = {
+  //     setup() {
+  //       slots = useSlots()
+  //       attrs = useAttrs()
+  //       return () => {}
+  //     }
+  //   }
+  //   const passedAttrs = { id: 'foo' }
+  //   const passedSlots = {
+  //     default: () => {},
+  //     x: () => {}
+  //   }
+  //   const root = document.createElement('div')
+  //   const vm = createApp(Comp).mount(root)
+  //   expect(typeof slots!.default).toBe('function')
+  //   expect(typeof slots!.x).toBe('function')
+  //   expect(attrs).toMatchObject(passedAttrs)
+  // })
+
+  test('useSlots / useAttrs (with args)', () => {
+    let slots: SetupContext['slots'] | undefined
+    let attrs: SetupContext['attrs'] | undefined
+    let ctx: SetupContext | undefined
+    const Comp = defineComponent({
+      setup(_, _ctx) {
+        slots = useSlots()
+        attrs = useAttrs()
+        ctx = _ctx
+        return () => {}
+      },
+    })
+    const root = document.createElement('div')
+    createApp(Comp, { foo: 'bar' }).mount(root)
+    expect(slots).toBe(ctx!.slots)
+    expect(attrs).toBe(ctx!.attrs)
+  })
+})