Repository: effector/effector
Pull request #480 (2021-04-09 10:08:28)

fixed bug with useGate position related to useStore and useVModel
thanks, @Spoki4 for reproduce bug with useGate
WIP: support vue 3 options api. Need to:
- write docs
- types


Patch:
diff --git a/.eslintrc b/.eslintrc
index fdf2d59e..f68bdf69 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -1,4 +1,4 @@
-parser: '@babel/eslint-parser'
+parser: @babel/eslint-parser
 parserOptions:
   ecmaVersion: 2018
 extends:
diff --git a/src/vue/lib/deepEqual.ts b/src/vue/lib/deepEqual.ts
new file mode 100644
index 00000000..d723b79a
--- /dev/null
+++ b/src/vue/lib/deepEqual.ts
@@ -0,0 +1,22 @@
+// @ts-nocheck
+export function deepEqual(x, y) {
+  if (x === y) {
+    return true
+  } else if (
+    typeof x == 'object' &&
+    x != null &&
+    typeof y == 'object' &&
+    y != null
+  ) {
+    if (Object.keys(x).length != Object.keys(y).length) return false
+
+    for (const prop in x) {
+      // eslint-disable-next-line no-prototype-builtins
+      if (y.hasOwnProperty(prop)) {
+        if (!deepEqual(x[prop], y[prop])) return false
+      } else return false
+    }
+
+    return true
+  } else return false
+}
diff --git a/src/vue/optionsApi.ts b/src/vue/optionsApi.ts
new file mode 100644
index 00000000..d63cb6e4
--- /dev/null
+++ b/src/vue/optionsApi.ts
@@ -0,0 +1,150 @@
+import {
+  is,
+  Store,
+  forward,
+  restore,
+  clearNode,
+  withRegion,
+  createEvent,
+} from 'effector'
+import {
+  Plugin,
+  ComponentOptions,
+  shallowRef,
+  watch,
+  WatchCallback,
+  WatchOptions,
+} from 'vue-next'
+
+import {unwrapProxy} from './lib/unwrapProxy'
+import {deepEqual} from './lib/deepEqual'
+import {deepCopy} from './lib/deepCopy'
+
+type Body = {
+  vModel: boolean
+  value: Store<any>
+}
+
+const mixin: ComponentOptions = {
+  data() {
+    let shape = this.$options.effector
+
+    if (!shape) return {}
+
+    if (typeof shape !== 'function') {
+      throw Error('[effector] option should be a function')
+    }
+
+    shape = shape.call(this)
+
+    if (typeof shape !== 'object') {
+      throw Error('Returned value should be an object')
+    }
+
+    // @ts-ignore
+    this.__clear = createEvent()
+
+    const config: Record<string, Body> = {}
+    const state: Record<string, any> = {}
+
+    // @ts-ignore
+    withRegion(this.__clear, () => {
+      for (const key in shape) {
+        const value = shape[key]
+
+        const watchOptions: WatchOptions = {deep: true, immediate: false}
+        let watchCallback: WatchCallback
+
+        let nextID = 0
+
+        let fromEvent = false
+        let isSelfUpdate = false
+
+        if (is.store(value)) {
+          config[key] = {
+            value,
+            vModel: false,
+          }
+        } else if (is.event(value) || is.effect(value)) {
+          config[key] = {
+            value: restore(
+              value.map(() => ++nextID),
+              null,
+            ),
+            vModel: false,
+          }
+        } else {
+          if (!is.store(value.value)) {
+            throw Error(`Effector property ${key}.value should be Store`)
+          }
+          config[key] = value
+        }
+
+        state[key] = shallowRef(config[key].value.getState())
+
+        config[key].value.updates.watch((payload: unknown) => {
+          if (isSelfUpdate) {
+            return
+          }
+
+          fromEvent = true
+          state[key].value = shallowRef(payload).value
+        })
+
+        if (!config[key].vModel) {
+          let temp: any = null
+
+          watchCallback = (newValue, oldValue) => {
+            if (!fromEvent) {
+              const isEqual = deepEqual(newValue, temp)
+
+              if (isEqual) {
+                temp = null
+                return
+              }
+
+              console.error(
+                `[${key}] is immutable value. You must update this value via effector events`,
+              )
+
+              temp = deepCopy(oldValue)
+              state[key].value = deepCopy(oldValue)
+            }
+
+            fromEvent = false
+          }
+        } else {
+          const updated = createEvent<unknown>()
+          forward({from: updated, to: value.value})
+
+          watchCallback = value => {
+            isSelfUpdate = true
+
+            if (!fromEvent) {
+              updated(unwrapProxy(value))
+            }
+
+            fromEvent = false
+            isSelfUpdate = false
+          }
+        }
+
+        watch(() => state[key].value, watchCallback, watchOptions)
+      }
+    })
+
+    return state
+  },
+
+  beforeUnmount() {
+    if (this.__clear) {
+      clearNode(this.__clear)
+    }
+  },
+}
+
+export const VueEffector: Plugin = {
+  install(app) {
+    app.mixin(mixin)
+  },
+}
diff --git a/src/vue/useStore.ts b/src/vue/useStore.ts
index 2f7358ef..cb8a7380 100644
--- a/src/vue/useStore.ts
+++ b/src/vue/useStore.ts
@@ -1,14 +1,11 @@
-import {Store, Subscription} from 'effector'
-import {onMounted, onUnmounted, readonly, shallowRef} from 'vue-next'
+import {Store} from 'effector'
+import {onUnmounted, readonly, shallowRef} from 'vue-next'
 
 export function useStore<T>(store: Store<T>) {
-  let unwatch: Subscription
   const _ = shallowRef(store.getState())
 
-  onMounted(() => {
-    unwatch = store.updates.watch(value => {
-      _.value = shallowRef(value).value
-    })
+  const unwatch = store.updates.watch(value => {
+    _.value = shallowRef(value).value
   })
 
   onUnmounted(() => {
diff --git a/src/vue/useVModel.ts b/src/vue/useVModel.ts
index adeaf1a6..271874a7 100644
--- a/src/vue/useVModel.ts
+++ b/src/vue/useVModel.ts
@@ -1,5 +1,6 @@
 import {Store, Subscription} from 'effector'
-import {onMounted, onUnmounted, ref, watch} from 'vue-next'
+import {onUnmounted, ref, watch} from 'vue-next'
+
 import {unwrapProxy} from './lib/unwrapProxy'
 import {deepCopy} from './lib/deepCopy'
 
@@ -10,15 +11,13 @@ export function useVModel<T>(store: Store<T>) {
   let fromEvent = false
   let unwatch: Subscription
 
-  onMounted(() => {
-    unwatch = store.updates.watch(payload => {
-      if (isSelfUpdate) {
-        return
-      }
+  unwatch = store.updates.watch(payload => {
+    if (isSelfUpdate) {
+      return
+    }
 
-      fromEvent = true
-      _.value = ref(deepCopy(payload)).value
-    })
+    fromEvent = true
+    _.value = ref(deepCopy(payload)).value
   })
 
   onUnmounted(() => {
diff --git a/website/client/sidebars.js b/website/client/sidebars.js
index b1e6eefa..367f30f4 100644
--- a/website/client/sidebars.js
+++ b/website/client/sidebars.js
@@ -1,6 +1,7 @@
 const sidebar = {
   Introduction: [
     'introduction/installation',
+    'introduction/core-concepts',
     'glossary',
     'introduction/examples',
     'ecosystem',
@@ -124,7 +125,10 @@ const sidebar = {
         {
           type: 'category',
           label: 'Hooks',
-          items: ['api/effector-vue/useStore', 'api/effector-vue/useVModel'],
+          items: [
+            'api/effector-vue/useStore',
+            'api/effector-vue/useVModel',
+          ],
         },
         'api/effector-vue/vue',
       ],


Test patch:
diff --git a/src/types/__tests__/effector/store.test.ts b/src/types/__tests__/effector/store.test.ts
index b9c1c4fa..def8f3ae 100644
--- a/src/types/__tests__/effector/store.test.ts
+++ b/src/types/__tests__/effector/store.test.ts
@@ -284,22 +284,3 @@ test('#thru', () => {
     "
   `)
 })
-
-test('unsafe widening (should fail)', () => {
-  //@ts-expect-error
-  const $values: Store<{
-    page: number
-    limit: number
-    [key: string]: any
-  }> = createStore({page: 0, limit: 0, id: 1})
-
-  expect(typecheck).toMatchInlineSnapshot(`
-    "
-    Type 'Store<{ page: number; limit: number; id: number; }>' is not assignable to type 'Store<{ [key: string]: any; page: number; limit: number; }>'.
-      Types of property 'updates' are incompatible.
-        Type 'Event<{ page: number; limit: number; id: number; }>' is not assignable to type 'Event<{ [key: string]: any; page: number; limit: number; }>'.
-          Types of parameters 'payload' and 'payload' are incompatible.
-            Type '{ [key: string]: any; page: number; limit: number; }' is not assignable to type '{ page: number; limit: number; id: number; }'.
-    "
-  `)
-})
diff --git a/src/vue/__tests__/createGate.test.ts b/src/vue/__tests__/createGate.test.ts
index e357ee4d..ba5e7947 100644
--- a/src/vue/__tests__/createGate.test.ts
+++ b/src/vue/__tests__/createGate.test.ts
@@ -1,7 +1,12 @@
 /* eslint-disable react-hooks/rules-of-hooks */
 import {shallowMount} from 'vue-test-utils-next'
-import {useStore, useGate, createGate} from 'effector-vue/composition'
-import {reactive, ref} from 'vue-next'
+import {
+  useStore,
+  useVModel,
+  useGate,
+  createGate,
+} from 'effector-vue/composition'
+import {reactive, ref, nextTick} from 'vue-next'
 import {createEvent, createStore} from 'effector'
 
 jest.mock('vue', () => require('vue-next'))
@@ -13,7 +18,7 @@ it('plain gate', async () => {
     template: `<div></div>`,
     setup() {
       useGate(Gate)
-    }
+    },
   })
   expect(Gate.status.getState()).toBeTruthy()
   wrapper.unmount()
@@ -23,37 +28,37 @@ it('plain gate', async () => {
 it('gate with props', () => {
   const Gate = createGate({
     defaultState: {
-      meet: 'Hello world'
-    }
+      meet: 'Hello world',
+    },
   })
 
   expect(Gate.state.getState()).toEqual({
-    meet: 'Hello world'
+    meet: 'Hello world',
   })
 
   const wrapper = shallowMount({
     template: `<div></div>`,
     setup() {
       useGate(Gate, () => ({
-        meet: 'Hello Vue Gate'
+        meet: 'Hello Vue Gate',
       }))
-    }
+    },
   })
 
   expect(Gate.state.getState()).toEqual({
-    meet: 'Hello Vue Gate'
+    meet: 'Hello Vue Gate',
   })
   wrapper.unmount()
   expect(Gate.state.getState()).toEqual({
-    meet: 'Hello world'
+    meet: 'Hello world',
   })
 })
 
 it('update props to gate from [ref]', async () => {
   const Gate = createGate({
     defaultState: {
-      meet: 'Hello world'
-    }
+      meet: 'Hello world',
+    },
   })
 
   const wrapper = shallowMount({
@@ -62,28 +67,28 @@ it('update props to gate from [ref]', async () => {
     `,
     setup() {
       const meet = ref('Hello Vue Gate')
-      const handleClick = () => meet.value = 'Hello effector'
+      const handleClick = () => (meet.value = 'Hello effector')
       useGate(Gate, () => ({meet: meet.value}))
       return {handleClick}
-    }
+    },
   })
 
   expect(Gate.state.getState()).toEqual({
-    meet: 'Hello Vue Gate'
+    meet: 'Hello Vue Gate',
   })
 
   await wrapper.find('button').trigger('click')
 
   expect(Gate.state.getState()).toEqual({
-    meet: 'Hello effector'
+    meet: 'Hello effector',
   })
 })
 
 it('update props to gate from [reactive]', async () => {
   const Gate = createGate({
     defaultState: {
-      meet: 'Hello world'
-    }
+      meet: 'Hello world',
+    },
   })
 
   const wrapper = shallowMount({
@@ -92,22 +97,22 @@ it('update props to gate from [reactive]', async () => {
     `,
     setup() {
       const obj = reactive({
-        meet: 'Hello Vue Gate'
+        meet: 'Hello Vue Gate',
       })
-      const handleClick = () => obj.meet = 'Hello effector'
+      const handleClick = () => (obj.meet = 'Hello effector')
       useGate(Gate, () => obj)
       return {handleClick}
-    }
+    },
   })
 
   expect(Gate.state.getState()).toEqual({
-    meet: 'Hello Vue Gate'
+    meet: 'Hello Vue Gate',
   })
 
   await wrapper.find('button').trigger('click')
 
   expect(Gate.state.getState()).toEqual({
-    meet: 'Hello effector'
+    meet: 'Hello effector',
   })
 })
 
@@ -129,10 +134,63 @@ it('works with effector store', async () => {
       return {
         updated,
       }
-    }
+    },
   })
 
   expect(Gate.state.getState()).toBe('John Doe')
   await wrapper.find('button').trigger('click')
   expect(Gate.state.getState()).toBe('Alan Doe')
-})
\ No newline at end of file
+})
+
+it('gate used before useStore hook', async () => {
+  const Gate = createGate()
+  const $loading = createStore(false).on(Gate.open, () => true)
+
+  const wrapper = shallowMount({
+    template: `
+      <div>
+        {{loading}}
+      </div>
+    `,
+    setup() {
+      useGate(Gate)
+      const loading = useStore($loading)
+
+      return {
+        loading,
+      }
+    },
+  })
+
+  await nextTick()
+  expect(wrapper.vm.$el.innerHTML).toBeTruthy()
+})
+
+it('gate used before useStore hook', async () => {
+  const Gate = createGate()
+  const $isEnabled = createStore(false).on(Gate.open, () => true)
+
+  const wrapper = shallowMount({
+    template: `
+      <div>
+        <input
+          type="checkbox"
+          v-model="isEnabled"
+          data-test="checkbox"
+        >
+      </div>
+    `,
+    setup() {
+      useGate(Gate)
+      const isEnabled = useVModel($isEnabled)
+
+      return {
+        isEnabled,
+      }
+    },
+  })
+
+  await nextTick()
+  // @ts-ignore
+  expect(wrapper.find('[data-test="checkbox"]').element.checked).toBeTruthy()
+})
diff --git a/src/vue/__tests__/optionsApi.test.ts b/src/vue/__tests__/optionsApi.test.ts
new file mode 100644
index 00000000..5b36db41
--- /dev/null
+++ b/src/vue/__tests__/optionsApi.test.ts
@@ -0,0 +1,350 @@
+import {defineComponent} from 'vue-next'
+import {mount, shallowMount} from 'vue-test-utils-next'
+import {createEvent, createStore} from 'effector'
+
+import {VueEffector} from '../optionsApi'
+
+jest.mock('vue', () => require('vue-next'))
+
+console.error = jest.fn()
+
+beforeEach(() => {
+  // @ts-ignore
+  console.error.mockClear()
+})
+
+const globalOptions = {
+  global: {
+    plugins: [VueEffector],
+  },
+}
+
+test('show counter', () => {
+  const counter$ = createStore(0)
+  const component = defineComponent({
+    template: `<div>{{counter$}}</div>`,
+    effector() {
+      return {
+        counter$,
+      }
+    },
+  })
+
+  const wrapper = shallowMount(component, {
+    ...globalOptions,
+  })
+
+  expect(wrapper.vm.counter$).toBe(0)
+  expect(wrapper.html()).toBe('<div>0</div>')
+})
+
+test('test SID if pass event to effector object', async () => {
+  const evt = createEvent()
+  const mockCallback = jest.fn()
+
+  const component = defineComponent({
+    template: '<div>{{ evt }}</div>',
+    effector() {
+      return {
+        evt,
+      }
+    },
+    watch: {
+      evt() {
+        mockCallback()
+      },
+    },
+  })
+
+  const wrapper = shallowMount(component, {
+    ...globalOptions,
+  })
+
+  evt()
+  await wrapper.vm.$nextTick()
+
+  expect(mockCallback.mock.calls.length).toBe(1)
+})
+
+test('vue component watch option', async () => {
+  const counter$ = createStore(0)
+  const mockCallback = jest.fn()
+
+  const component = defineComponent({
+    template: '<div>{{ counter$ }}</div>',
+    effector() {
+      return {
+        counter$,
+      }
+    },
+    watch: {
+      counter$() {
+        mockCallback()
+      },
+    },
+  })
+
+  const wrapper = shallowMount(component, {
+    ...globalOptions,
+  })
+
+  expect(wrapper.vm.counter$).toBe(0)
+
+  //@ts-ignore
+  counter$.setState(1)
+  await wrapper.vm.$nextTick()
+
+  expect(wrapper.vm.counter$).toBe(1)
+  expect(mockCallback.mock.calls.length).toBe(1)
+})
+
+test('is v-model works correct with input', () => {
+  const expected = 'John Doe'
+
+  const fullname$ = createStore('')
+  const component = defineComponent({
+    template: `<div><input v-model="fullname$" data-input></div>`,
+    effector() {
+      return {
+        fullname$: {
+          value: fullname$,
+          vModel: true,
+        },
+      }
+    },
+  })
+
+  const wrapper = shallowMount(component, {
+    ...globalOptions,
+  })
+
+  wrapper.find('[data-input]').setValue(expected)
+
+  // @ts-ignore
+  expect(wrapper.vm.fullname$).toBe(expected)
+})
+
+test('is v-model works with custom component', async () => {
+  const expected = 'John Doe'
+
+  const child = defineComponent({
+    name: 'child',
+    template: `<div></div>`,
+    methods: {
+      handleChange() {
+        this.$emit('update:modelValue', expected)
+      },
+    },
+  })
+
+  const fullname$ = createStore('')
+  const component = defineComponent({
+    template: `
+      <div>
+        <child v-model="fullname$"/>
+      </div>
+    `,
+
+    components: {
+      child,
+    },
+
+    effector() {
+      return {
+        fullname$: {
+          value: fullname$,
+          vModel: true,
+        },
+      }
+    },
+  })
+
+  const wrapper = mount(component, {
+    ...globalOptions,
+  })
+
+  wrapper.findComponent({name: 'child'}).vm.handleChange()
+  expect(wrapper.vm.fullname$).toBe(expected)
+})
+
+test('non v-model usage support', async () => {
+  const changeEvent = createEvent()
+  const someEvt = createEvent()
+
+  const store$ = createStore('')
+    .on(changeEvent, (_, value) => value)
+    .on(someEvt, () => 'some-value')
+
+  const component = defineComponent({
+    template: `
+      <div id="app">
+        <input id="input" :value="store$" @input="(e) => changeEvent(e.target.value)" />
+        <button id="btn" @click="someEvt">set some-value</button>
+      </div>
+    `,
+    name: 'App',
+    effector() {
+      return {
+        store$,
+      }
+    },
+    methods: {
+      changeEvent,
+      someEvt,
+    },
+  })
+
+  const wrapper = mount(component, {
+    ...globalOptions,
+  })
+  const input = wrapper.element.querySelector('#input')! as HTMLInputElement
+  const btn = wrapper.element.querySelector('#btn')! as HTMLButtonElement
+  btn.click()
+
+  await wrapper.vm.$nextTick()
+
+  expect(store$.getState()).toBe('some-value')
+  expect(input.value).toBe('some-value')
+})
+
+test('change value in array from UI via v-model', async () => {
+  const handleChange = createEvent<number>()
+
+  const users$ = createStore<{name: string; selected: boolean}[]>([
+    {name: 'Alan', selected: false},
+    {name: 'Criss', selected: false},
+    {name: 'John', selected: false},
+  ])
+
+  users$.on(handleChange, (state, payload) => {
+    const idx = state.findIndex((_, i) => i === payload)
+    const s = [...state]
+
+    s[idx].selected = !s[idx].selected
+    return s
+  })
+
+  const component = defineComponent({
+    template: `
+      <div>
+        <label
+          v-for="(user, key) in users$"
+          :key="key"
+        >
+        <input
+          type="checkbox"
+          :value="user"
+          @change="handleChange(key)"
+          data-test="checkbox"
+        >
+          {{user.name}}
+        </label>
+      </div>
+    `,
+
+    effector() {
+      return {
+        users$,
+      }
+    },
+
+    methods: {
+      handleChange,
+    },
+  })
+
+  const wrapper = mount(component, {
+    ...globalOptions,
+  })
+  const checkbox = wrapper.find('[data-test="checkbox"]')
+  // @ts-ignore
+  await checkbox.setChecked()
+
+  expect(users$.getState()[0].selected).toBeTruthy()
+})
+
+test('change value in array from event via v-model', async () => {
+  const handleChange = createEvent<number>()
+
+  const users$ = createStore<{name: string; selected: boolean}[]>([
+    {name: 'Alan', selected: false},
+    {name: 'Criss', selected: false},
+    {name: 'John', selected: false},
+  ])
+
+  users$.on(handleChange, (state, payload) => {
+    const idx = state.findIndex((_, i) => i === payload)
+    const s = [...state]
+
+    s[idx].selected = !s[idx].selected
+    return s
+  })
+
+  const component = defineComponent({
+    template: `
+      <div>
+        <label
+          v-for="(user, key) in users$"
+          :key="key"
+        >
+        <input
+          type="checkbox"
+          :value="user"
+          @change="handleChange(key)"
+          data-test="checkbox"
+        >
+          {{user.name}}
+        </label>
+
+        <button @click="handleChange(0)" data-test="btn">
+          Change
+        </button>
+      </div>
+    `,
+
+    effector() {
+      return {
+        users$,
+      }
+    },
+
+    methods: {
+      handleChange,
+    },
+  })
+
+  const wrapper = mount(component, {
+    ...globalOptions,
+  })
+  const btn = wrapper.find('[data-test="btn"]')
+  await btn.trigger('click')
+
+  expect(users$.getState()[0].selected).toBeTruthy()
+})
+
+test('effector store is readonly for UI', async () => {
+  const counter$ = createStore(0)
+
+  const component = defineComponent({
+    template: `<div></div>`,
+    effector() {
+      return {
+        counter$,
+      }
+    },
+    methods: {
+      handleChange() {
+        // @ts-ignore
+        this.counter$ = 15
+      },
+    },
+  })
+  const wrapper = shallowMount(component, {
+    ...globalOptions,
+  })
+
+  wrapper.vm.handleChange()
+  await wrapper.vm.$nextTick()
+
+  expect(console.error).toHaveBeenCalledTimes(1)
+})