Repository: LinusBorg/vue-mixable Branch: main Commit: 9864d162b0ec Files: 40 Total size: 46.0 KB Directory structure: gitextract_tjv42v88/ ├── .eslintignore ├── .eslintrc.cjs ├── .github/ │ └── workflows/ │ ├── ci.yml │ └── size.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .release-it.json ├── .vscode/ │ └── extensions.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── commitlint.config.js ├── index.html ├── package.json ├── src/ │ ├── __tests__/ │ │ ├── advanced.spec.ts │ │ ├── cache.spec.ts │ │ ├── computed.spec.ts │ │ ├── data.spec.ts │ │ ├── helpers.ts │ │ ├── inject.spec.ts │ │ ├── lifecylce.spec.ts │ │ ├── methods.spec.ts │ │ ├── propsEmits.spec.ts │ │ ├── setup.ts │ │ └── watch.spec.ts │ ├── createComposable.ts │ ├── defineMixin.ts │ ├── env.d.ts │ ├── index.ts │ ├── inject.ts │ ├── typeTest.ts │ ├── types.ts │ ├── utils.ts │ └── vmContextProxy.ts ├── tsconfig.app.json ├── tsconfig.config.json ├── tsconfig.json ├── tsconfig.vitest.json └── vite.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .eslintignore ================================================ /coverage /dist ================================================ FILE: .eslintrc.cjs ================================================ /* eslint-env node */ module.exports = { root: true, extends: ['@linusborg/eslint-config', 'plugin:vue/vue3-essential'], parserOptions: { ecmaVersion: 'latest', }, overrides: [ { files: ['*.js', '.cjs'], env: { node: true, }, }, ], } ================================================ FILE: .github/workflows/ci.yml ================================================ name: 'ci' on: push: branches: - '**' pull_request: branches: - main permissions: contents: read # to fetch code (actions/checkout) jobs: build-and-typecheck: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Install pnpm uses: pnpm/action-setup@v2 - name: Set node version to 16 uses: actions/setup-node@v3 with: node-version: 16 cache: 'pnpm' - run: pnpm install - name: Run build & tsc run: pnpm run build unit-test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Install pnpm uses: pnpm/action-setup@v2 - name: Set node version to 16 uses: actions/setup-node@v3 with: node-version: 16 cache: 'pnpm' - run: pnpm install - name: Run unit tests run: pnpm run test:unit lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Install pnpm uses: pnpm/action-setup@v2 - name: Set node version to 16 uses: actions/setup-node@v3 with: node-version: 16 cache: 'pnpm' - run: pnpm install - name: Lint codebase run: pnpm run lint ================================================ FILE: .github/workflows/size.yml ================================================ name: "size" on: pull_request: branches: - main jobs: size: runs-on: ubuntu-latest env: CI_JOB_NUMBER: 1 steps: - uses: actions/checkout@v1 - name: Install pnpm uses: pnpm/action-setup@v2 - uses: andresz1/size-limit-action@v1 with: github_token: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* node_modules .DS_Store dist dist-ssr coverage *.local /cypress/videos/ /cypress/screenshots/ # Editor directories and files .vscode/* !.vscode/extensions.json .idea *.suo *.ntvs* *.njsproj *.sln *.sw? tsconfig.app.tsbuildinfo ================================================ FILE: .prettierignore ================================================ dist/ /types ================================================ FILE: .prettierrc.json ================================================ { "useTabs": false, "singleQuote": true, "trailingComma": "es5", "semi": false } ================================================ FILE: .release-it.json ================================================ { "git": { "commitMessage": "chore: release v${version}" }, "github": { "release": true }, "plugins": { "@release-it/conventional-changelog": { "preset": "angular", "infile": "CHANGELOG.md" } }, "hooks": { "before:init": ["pnpm lint:ci", "pnpm test:ci"], "after:bump": ["pnpm build"], "after:release": ["echo 🥳 Successfully released ${name} v${version}."] } } ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"] } ================================================ FILE: CHANGELOG.md ================================================ ## [0.3.1](https://github.com/LinusBorg/vue-mixable/compare/0.3.0...0.3.1) (2022-11-17) ### Bug Fixes * **vm:** ensure custom properties can be set and read ([784882f](https://github.com/LinusBorg/vue-mixable/commit/784882febd52ee3c3e07d0ab1b19285c44609b22)) # 0.3.0 (2022-11-07) ### Bug Fixes * add license field to package.json, add repo URL ([c1acfe6](https://github.com/LinusBorg/vue-mixable/commit/c1acfe6eb2a882d8a893f191d7ea96e644b08392)) * add LICENSE file ([52b59b5](https://github.com/LinusBorg/vue-mixable/commit/52b59b5620f29e9109f2ab461621851a5135424f)) * add proper files option content ([47df2e7](https://github.com/LinusBorg/vue-mixable/commit/47df2e701a1299eef5c4aa4060cfeb69ea757ead)) * ensure cache works with the new types. ([babada7](https://github.com/LinusBorg/vue-mixable/commit/babada7b499e0fa6c3aa6e67402aac1d3857b99d)) * isString should properly guard string type ([1629187](https://github.com/LinusBorg/vue-mixable/commit/16291871c46a7065bf5193f08a89b8c0823a9095)) * make vue peer dep ([c8e73ff](https://github.com/LinusBorg/vue-mixable/commit/c8e73ff0ecebec1e3a1625265b21333e6884635b)) * type in release-it config file name, add missing plugin package. ([7cae268](https://github.com/LinusBorg/vue-mixable/commit/7cae268f7d45fa4fed9b147bb15a6b0c22645d4e)) * **types:** return type should include props and emits keys ([4c38321](https://github.com/LinusBorg/vue-mixable/commit/4c383217a67c54e0b423fdefd96c024fa605ab7a)) ### Features * add composable cache ([0838c4b](https://github.com/LinusBorg/vue-mixable/commit/0838c4b63811422f42c6adfd436d1a2300cadc4c)) * defineMixin() allows to write typesafe mixins. ([1fd5e93](https://github.com/LinusBorg/vue-mixable/commit/1fd5e930f28745218519c01d34215b511458d3a7)) * export defineMixin() ([0b584ec](https://github.com/LinusBorg/vue-mixable/commit/0b584ec12c924d7a04846567cdbf08ef6ffc0ea6)) ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2022 - present Thorsten Lünborg Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ ![current npm version](https://img.shields.io/npm/v/vue-mixable) ![npm bundle size (min+gzip)](https://badgen.net/bundlephobia/minzip/vue-mixable) ![npm downloads per month](https://img.shields.io/npm/dm/vue-mixable) ![NPM](https://img.shields.io/npm/l/vue-mixable) ![CI checks](https://badgen.net/github/checks/linusborg/vue-mixable) ![types included](https://badgen.net/npm/types/vue-mixable) # 🌪 `vue-mixable` > Convert mixins into composables to reuse them in Composition API * helpful during Options API -> Composition API migrations / in mixed code-bases * simple API - one function call is all you need * TS Support (with small caveats) * small footprint: ![npm bundle size (min+zip)](https://badgen.net/bundlephobia/minzip/vue-mixable) ## Quick Intro ```js // given an existing mixin such as this: export const messageMixin = { data() { return { msg: 'Hello World' } }, computed: { loudMsg() { return this.capitalize(this.msg) + '!!!1eleven!' } }, methods: { capitalize(value) { return value.toUpperCase() } } } // we can create a composable from it with a single function call import { createComposableFromMixin } from 'vue-mixable' export const useMessage = createComposableFromMixin(messageMixin) ``` This composable can then be used in `setup()`/ ` ``` ## Use cases This library is primarily useful for developers trying to migrate a Options-API codebase using Mixins for code sharing to Composition API using composables for code sharing. One of the challenges in such a migration is that one often cannot rewrite a mixin into a composable and replace all of that mixin's usage instances in the app at once, epsecially when mixins depend on one another, which is often the case in larger code-bases. This is where `vue-mixable` can help: The team can keep all their mixins for the time of the migration, but convert each of them into composables with one line of code. You get have your cake, and eat it to, in a way. Then they can migrate individual components from the mixin to the composable at their own pace, and once the migration is done, they can rewrite the mixin into a proper standalone composable and finally remove the mixin from your codebase. ## Installation ```bash npm install vue-mixable ``` ## Usage Notes ### Supported APIs `vue-mixable` provides full support for mixins that use the following Options APIs * `data` * `computed` * `methods` * `watch` * `provide` * `inject` * `props` (see Note in the next section) * `emits` (see Note in the next section) Options with no direct support (currently): * `inheritAttrs` * `components` * `directives` * `name` If you use any of the above options, you would have to set them manually in any component that uses the generated composable instead of the original mixin. ### `props` & `emits` options Mixins can contain props definitions. Composables cannot, as they are functions invoked during component initialization (in `setup()`, at which point props must have been defined already. `vue-mixable` solves with in the following way: ```js const mixin = { props: ['modelValue', 'age', 'street', 'city'], emits: ['modelValue:update', 'certified'] // ...and other mixin content, i.e.: data: () => ({ //... }) } export const usePerson = createComposableFromMixin(mixin) // props and emits options will be available // as properties on the composable function(!) usePerson.props // => ['modelValue', 'age', 'street', 'city'] usePerson.emits // => ['modelValue:update', 'certified'] ``` Usage ```js import { usePerson } from '...' export default defineComponent({ props: ['firstname', 'lastname', ...usePerson.props], emits: usePerson.emits, setup(props, { emit }) { const person = usePerson() return { } } }) ``` ### Shape of the composable's return value The shape of the return value is essentially a flattened version of the mixins `data`, `computed` and `methods` properties, with `data` and `computed` being `ref()`'s. All other supported properties (lifecylces, `watch`) have nothing to expose externally. ```js const mixin = { data: () =>({ a: 'A', b: 'B', }), computed: { c() { return this.A }, d() { return this.B } }, methods: { e() { return callSomething(this.a, this.c) } } } const useComposable = createComposableFromMixin(mixin) ``` would be turned into: ```js const { a, // ref('A') b, // ref('B') c, // computed ref d, // computed ref e, // normal function } = useComposable() ``` ### Feature Roadmap - [ ] Support Mixins that implicitly depend on properties/APIs from other mixins. - [ ] Support Nested Mixins. - [ ] Exclude specific properties from composables return value (essentially making some mixin properties private in the composable). Out of scope / not planned - [ ] mixins with implicit circular dependencies on one another. ## Caveats ### `this.$watch()` in created creating a watcher imperatively in `created` will likely not work as expected, because in the created composable, that hooks is run before `setup()` returns, so any data properties declared in the mixin/composable will be missing on `this`. Possible workarounds: - use the normal `watch:`option - create the watcher in `beforeMount`. ## Typescript Support Typescript support is still considered unstable as we plan on improving the types, possibly introduction breaking changes to the types. **Caveats:** * For Type inference to work, each mixin object *must* have a `props` key. If your mixin does not include any props, set it to an empty object. * props always need to be defined in object style. array style is currently not supported and will break type inference. * the `emits` option cannot be provided in its array form, it must take the more verbose object form. ```ts const mixin = defineMixin({ props: {} // needed for proper tyep inference for now, emits: { 'update:modelValue': (v?: any) => true, // this validator can be a NOOP returning `true` }, data: () => ({ // ... }) }) const composable = createCopmposableFromMixin(mixin) ``` ### `defineMixin()` This function does not do anything at runtime, it's just providing tpe inferrence for your mixins: ```ts const mixin = { data: () => ({ msg: 'Hello World', }), methods: { test() { this.msg // not inferreed correctly } } } // better: import { defineMixin } from 'vue-mixable' const mixin = defineMixin({ props: {}, // needed, see caveat explained further up. data: () => ({ msg: 'Hello World', }), methods: { test() { this.msg // properly inferred. } } } ``` ### `createComposableFromMixin()` This function will offer full type inference for any mixin passed to it. ## Developer Instructions ### Compile and Minify for Production, create Type Declarations ```sh pnpm build ``` ### Run Unit Tests with [Vitest](https://vitest.dev/) ```sh pnpm test:unit ``` ### Lint with [ESLint](https://eslint.org/) ```sh pnpm lint ``` ================================================ FILE: commitlint.config.js ================================================ const config = require('@commitlint/config-angular') module.exports = { extends: ['@commitlint/config-angular'], rules: { 'type-enum': [2, 'always', [...config.rules['type-enum'][2], 'chore']], }, } ================================================ FILE: index.html ================================================ Vite App
================================================ FILE: package.json ================================================ { "name": "vue-mixable", "description": "Turn Vue Mixins into Composables with a simple helper function", "version": "0.3.1", "license": "MIT", "keywords": [ "Vue", "composables", "mixins", "Vue plugin", "migration", "compat" ], "author": { "name": "Thorsten Lünborg", "url": "https://github.com/linusborg" }, "repository": { "url": "https://github.com/LinusBorg/vue-mixable", "type": "git" }, "main": "./dist/index.cjs", "module": "./dist/index.mjs", "unpkg": "./dist/index.js", "jsdelivr": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", "require": "./dist/index.cjs", "import": "./dist/index.mjs", "default": "./dist/index.cjs" }, "./package.json": "./package.json" }, "files": [ "dist", "README.md", "LICENSE", "src" ], "scripts": { "dev": "vite", "build": "vite build && tsc --declaration --emitDeclarationOnly -p tsconfig.app.json --outDir dist", "type-check": "tsc --noEmit -p tsconfig.vitest.json --composite false", "test:unit": "vitest --environment jsdom", "test:ci": "vitest --environment jsdom --run", "lint:ci": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts", "lint": "pnpm lint:ci --fix", "size": "pnpm size-limit", "release": "release-it", "update-hooks": "pnpm simple-git-hooks" }, "packageManager": "pnpm@7.14.1", "simple-git-hooks": { "pre-commit": "pnpm lint-staged", "commit-msg": "pnpm commitlint --edit $1" }, "lint-staged": { "*.{ts,js,cjs,vue}": "pnpm exec eslint --max-warnings 0" }, "size-limit": [ { "path": "dist/index.cjs", "limit": "2kB" } ], "devDependencies": { "@commitlint/cli": "^17.2.0", "@commitlint/config-angular": "^17.2.0", "@linusborg/eslint-config": "^0.3.0", "@release-it/conventional-changelog": "^5.1.1", "@size-limit/preset-small-lib": "^8.1.0", "@types/jsdom": "^20.0.1", "@types/node": "^16.18.3", "@vitest/coverage-c8": "^0.25.0", "@vue/test-utils": "^2.1.0", "@vue/tsconfig": "^0.1.3", "eslint": "^8.27.0", "jsdom": "^20.0.2", "lint-staged": "^13.0.3", "prettier": "^2.7.1", "release-it": "^15.5.0", "simple-git-hooks": "^2.8.1", "size-limit": "^8.1.0", "type-fest": "^3.2.0", "typescript": "~4.7.4", "vite": "^3.2.3", "vitest": "^0.25.0", "vue": "^3.2.41" }, "peerDependencies": { "vue": "^3.2" }, "peerDependenciesMeta": { "vue": { "optional": true } } } ================================================ FILE: src/__tests__/advanced.spec.ts ================================================ import { describe, test, expect, vi } from 'vitest' import { createComposableFromMixin } from '../createComposable' import { defineMixin } from '../defineMixin' import { wrapComposable } from './helpers' // FIXME: needs to wait until feature is done. describe.skip('advanced', () => { test('nested mixins', async () => { const innerSpy = vi.fn() const outerSpy = vi.fn() const innerMixin = defineMixin({ props: { inner: Number, }, data: () => ({ nestedProperty: 'A', sharedProperty: 'A', }), created() { innerSpy() }, mounted() { innerSpy() }, }) const outerMixin = defineMixin({ mixins: [innerMixin], props: { outer: String, }, data: () => ({ msg: 'Hello World', sharedProperty: 'B', }), computed: { getNestedProperty(): string { return this.nestedProperty }, }, mounted() { outerSpy() }, }) // eslint-disable-next-line no-autofix/unused-imports/no-unused-vars const innerComposable = createComposableFromMixin(innerMixin) const outerComposable = createComposableFromMixin(outerMixin) const wrapper = wrapComposable( outerComposable, { props: { outer: 'Hello', inner: 10, }, }, { props: outerComposable.props, } ) // Props expect(wrapper.vm.outer).toBe('Hello') expect(wrapper.vm.inner).toBe(10) // Data & computed expect(wrapper.vm.sharedProperty).toBe('B') expect(wrapper.vm.getNestedProperty).toBe('A') // Lifecycle Hooks expect(innerSpy).toHaveBeenCalledTimes(2) expect(outerSpy).toHaveBeenCalledTimes(1) }) }) ================================================ FILE: src/__tests__/cache.spec.ts ================================================ import { describe, test, expect } from 'vitest' import { createComposableFromMixin } from '../createComposable' import { defineMixin } from '../defineMixin' describe('cache', () => { test('works', () => { const mixin = defineMixin({ props: {}, data: () => ({ msg: 'Hello World', }), }) const composable1 = createComposableFromMixin(mixin) const composable2 = createComposableFromMixin(mixin) expect(composable1).toBe(composable2) }) }) ================================================ FILE: src/__tests__/computed.spec.ts ================================================ import { describe, test, expect } from 'vitest' import { createComposableFromMixin } from '../createComposable' import { defineMixin } from '../defineMixin' import { wrapComposable } from './helpers' describe('computed option', async () => { test('computed are readable', async () => { const mixin = defineMixin({ props: {}, computed: { msg() { return 'msg' }, }, } as const) const composable = createComposableFromMixin(mixin) const wrapper = wrapComposable(composable) expect((wrapper.vm as any).msg).toBe('msg') }) test('writeable computeds work', async () => { const mixin = defineMixin({ props: {}, data: () => ({ internalMsg: 'A', }), computed: { msg: { get(): string { return this.internalMsg }, set(v: string) { this.internalMsg = v }, }, }, } as const) const composable = createComposableFromMixin(mixin) const wrapper = wrapComposable( composable, {}, { template: `
{{ msg }}
`, } ) expect(wrapper.find('div').text()).toBe('A') await wrapper.find('button').trigger('click') expect(wrapper.find('div').text()).toBe('B') }) test('computed have access to `this`', async () => { const mixin = defineMixin({ props: {}, computed: { msg() { return !!this.$emit }, }, } as const) const composable = createComposableFromMixin(mixin) const wrapper = wrapComposable(composable) expect((wrapper.vm as any).msg).toBe(true) }) test('computed have access to data from mixin', async () => { const mixin = defineMixin({ props: {}, data: () => ({ foo: 'Hello World', }), computed: { msg(): string { return this.foo }, }, } as const) const composable = createComposableFromMixin(mixin) const wrapper = wrapComposable(composable) expect((wrapper.vm as any).msg).toBe('Hello World') }) }) ================================================ FILE: src/__tests__/data.spec.ts ================================================ import { describe, test, expect } from 'vitest' import { nextTick } from 'vue' import { createComposableFromMixin } from '../createComposable' import { defineMixin } from '../defineMixin' import { wrapComposable } from './helpers' describe('data Option', () => { test('data usable in template', async () => { const mixin = defineMixin({ props: {}, data: () => ({ msg: 'Hello World', }), } as const) const composable = createComposableFromMixin(mixin) // const wrapper = const wrapper = wrapComposable(composable) expect(wrapper.text()).toBe('Hello World') }) test('data fn receives instance proxy', async () => { const mixin = defineMixin({ props: {}, data: (vm: any) => { return { msg: vm.$emit ? 'Hello World' : '', } }, } as const) const composable = createComposableFromMixin(mixin) // const wrapper = const wrapper = wrapComposable(composable, {}, { withProxy: true }) expect(wrapper.text()).toBe('Hello World') }) test('data properties can be reactively reassigned', async () => { const mixin = defineMixin({ props: {}, data: () => { return { msg: 'A', } }, } as const) const composable = createComposableFromMixin(mixin) const wrapper = wrapComposable( composable, {}, { template: `
{{ msg }}
`, } ) expect(wrapper.find('div').text()).toBe('A') await wrapper.find('button').trigger('click') await nextTick() expect(wrapper.find('div').text()).toBe('B') }) }) ================================================ FILE: src/__tests__/helpers.ts ================================================ import { mount, type MountingOptions } from '@vue/test-utils' import { defineComponent } from 'vue' export const wrapComposable = >( composable: any, options: O = {} as O, extensions: Record = {} ) => mount( defineComponent({ ...extensions, template: extensions.template ?? '
{{ msg }}
', setup() { return { ...composable(), } }, }), options ) ================================================ FILE: src/__tests__/inject.spec.ts ================================================ import { describe, test, expect } from 'vitest' import { defineComponent, ref } from 'vue' import { createComposableFromMixin } from '../createComposable' import { defineMixin } from '../defineMixin' import { wrapComposable } from './helpers' describe('inject option', async () => { test('injects from mixin work', async () => { const mixin = defineMixin({ props: {}, inject: { foo: { from: 'foo', default: 'bar', }, }, data(): { msg: string } { return { msg: (this as any).foo, } }, }) const composable = createComposableFromMixin(mixin) const wrapper = wrapComposable( composable, {}, { template: `
{{ msg }}
`, } ) expect(wrapper.vm.msg).toBe('bar') }) test('provide works', async () => { const foo = ref('foo') const mixin = defineMixin({ props: {}, provide: { foo, }, }) const Child = defineComponent({ inject: ['foo'], template: `
{{ foo }}
`, }) const composable = createComposableFromMixin(mixin) const wrapper = wrapComposable( composable, {}, { template: `
`, components: { Child, }, } ) const child = wrapper.findComponent(Child) expect(child.text()).toBe('foo') await child.find('div').trigger('click') expect(foo.value).toBe('bar') expect(child.text()).toBe('bar') }) }) ================================================ FILE: src/__tests__/lifecylce.spec.ts ================================================ import { describe, test, expect, vi } from 'vitest' import { createComposableFromMixin } from '../createComposable' import { defineMixin } from '../defineMixin' import { wrapComposable } from './helpers' describe('Lifecycle hooks', async () => { test('are called with proper `this`', async () => { const spy = vi.fn() function testHandler() { // @ts-expect-error - doesn't like this for some reason if ((this as any).$emit) spy() } const mixin = defineMixin({ props: {}, data: () => ({ msg: 'A', }), beforeCreate: testHandler, created: testHandler, beforeMount: testHandler, mounted: testHandler, beforeUpdate: testHandler, updated: testHandler, beforeUnmount: testHandler, unmounted: testHandler, }) const composable = createComposableFromMixin(mixin) const wrapper = wrapComposable( composable, {}, { template: `
{{ msg }}
`, } ) expect(spy).toHaveBeenCalledTimes(4) await wrapper.find('button').trigger('click') expect(spy).toHaveBeenCalledTimes(6) await wrapper.unmount() expect(spy).toHaveBeenCalledTimes(8) }) test('can read and set custom properties on `this`', async () => { const mixin = defineMixin({ props: {}, beforeCreate() { ;(this as any).custom = 'A' }, created() { expect((this as any).custom).toBe('A') }, }) const composable = createComposableFromMixin(mixin) const wrapper = wrapComposable( composable, {}, { template: `
`, } ) expect((wrapper.vm as any).custom).toBe('A') }) }) ================================================ FILE: src/__tests__/methods.spec.ts ================================================ import { describe, test, expect } from 'vitest' import type { ComponentPublicInstance } from 'vue' import { createComposableFromMixin } from '../createComposable' import { defineMixin } from '../defineMixin' import { wrapComposable } from './helpers' describe('methods option', () => { test('methods are callable', async () => { const mixin = defineMixin({ props: {}, methods: { msg() { return 'msg' }, }, }) const composable = createComposableFromMixin(mixin) const wrapper = wrapComposable(composable) expect((wrapper.vm as any).msg()).toBe('msg') }) test('methods have access to `this`', async () => { const mixin = defineMixin({ props: {}, methods: { msg() { return !!(this as unknown as ComponentPublicInstance).$emit }, }, }) const composable = createComposableFromMixin(mixin) const wrapper = wrapComposable(composable) expect((wrapper.vm as any).msg()).toBe(true) }) test('methods have access to data from mixin', async () => { const mixin = defineMixin({ props: {}, data: () => ({ msg: 'Hello World', }), methods: { getMsg() { return (this as any).msg }, }, }) const composable = createComposableFromMixin(mixin) const wrapper = wrapComposable(composable) expect((wrapper.vm as any).getMsg()).toBe('Hello World') }) }) ================================================ FILE: src/__tests__/propsEmits.spec.ts ================================================ import { describe, test, expect, vi } from 'vitest' import { defineComponent } from 'vue' import { mount } from '@vue/test-utils' import { createComposableFromMixin } from '../createComposable' import { defineMixin } from '../defineMixin' // import { wrapComposable } from './helpers' describe('props', () => { test('props as property on composable', async () => { const mixin = defineMixin({ props: { foo: String, }, }) const composable = createComposableFromMixin(mixin) const Comp = defineComponent({ props: composable.props, template: `
{{ foo }}
`, }) const wrapper = await mount(Comp, { props: { foo: 'bar', }, }) expect(wrapper.find('div').text()).toBe('bar') }) }) describe('emits', () => { test('emits option on composable', async () => { const mixin = defineMixin({ props: {}, emits: ['foo'], }) const composable = createComposableFromMixin(mixin) const Comp = defineComponent({ emits: composable.emits, template: ``, }) const spy = vi.fn() const wrapper = await mount(Comp, { props: { onFoo: spy, }, }) await wrapper.find('button').trigger('click') expect(spy).toHaveBeenCalledTimes(1) expect(wrapper.emitted()).toHaveProperty('foo') }) }) ================================================ FILE: src/__tests__/setup.ts ================================================ import { config } from '@vue/test-utils' config.global.config.unwrapInjectedRef = true ================================================ FILE: src/__tests__/watch.spec.ts ================================================ import { describe, test, expect, vi } from 'vitest' import { nextTick } from 'vue' import { createComposableFromMixin } from '../createComposable' import { defineMixin } from '../defineMixin' import { wrapComposable } from './helpers' describe('watch option', async () => { test('string watch', async () => { const spy = vi.fn() const mixin = defineMixin({ props: {}, data: () => ({ msg: 'A', }), watch: { msg(...args: any[]) { spy(...args) }, }, }) const composable = createComposableFromMixin(mixin) const wrapper = wrapComposable( composable, {}, { template: `
{{ msg }}
`, } ) expect(spy).toHaveBeenCalledTimes(0) await wrapper.find('button').trigger('click') await nextTick() expect(wrapper.vm.msg).toBe('B') expect(spy).toHaveBeenCalledTimes(1) expect(spy).toHaveBeenCalledWith('B', 'A', expect.any(Function)) }) }) ================================================ FILE: src/createComposable.ts ================================================ /** * Code in this file is based on the Options API implementation in Vue 3's codebase: * https://github.com/vuejs/core/blob/f67bb500b6071bc0e55a89709a495a27da73badd/packages/runtime-core/src/componentOptions.ts */ import { onBeforeMount, onMounted, onBeforeUpdate, onUpdated, onActivated, onDeactivated, onBeforeUnmount, onUnmounted, onRenderTracked, onRenderTriggered, onErrorCaptured, getCurrentInstance, ref, computed, watch, reactive, provide, type ComponentPublicInstance, type WatchCallback, } from 'vue' import { callHook, isArray, isFunction, isObject, isString } from './utils' import { createContextProxy } from './vmContextProxy' import type { ComputedOptions, MethodOptions, ComponentOptionsWithObjectProps, ComponentOptionsMixin, ToRefs, CreateComponentPublicInstance, ExtractPropTypes, ComponentPropsOptions, ExtractDefaultPropTypes, EmitsOptions, } from 'vue' // import { cache } from './cache' import type { ComponentWatchOptionItem, ExtractComputedReturns, EmitsToProps, } from './types' import { resolveInjections } from './inject' export const cache = new Map() // TODO: properly type this export /* @__PURE__ */ function createComposableFromMixin< Props extends Readonly> & EmitsToProps, VM extends CreateComponentPublicInstance< Props, RawBindings, D, C, M, Mixin, Extends, E, Props, ExtractDefaultPropTypes, false >, PropsOptions extends Readonly, RawBindings, D, C extends ComputedOptions = {}, M extends MethodOptions = {}, Mixin extends ComponentOptionsMixin = ComponentOptionsMixin, Extends extends ComponentOptionsMixin = ComponentOptionsMixin, E extends EmitsOptions = {}, EE extends string = string >( mixin: ComponentOptionsWithObjectProps< PropsOptions, RawBindings, D, C, M, Mixin, Extends, E, EE > ): (() => ToRefs & ToRefs> & M) & { props: PropsOptions emits: E } { const { props, emits, data: dataFn, computed: computedOptions, methods, watch, provide: provideOptions, inject: injectOptions, // Lifecylce Hooks created, beforeCreate, beforeMount, mounted, beforeUpdate, updated, activated, deactivated, beforeUnmount, unmounted, renderTracked, renderTriggered, errorCaptured, // mixins, } = mixin if (cache.has(mixin)) { return cache.get(mixin) as any } const composable = () => { const instance = getCurrentInstance()! const vm = instance.proxy! as VM // if (mixins) { // mixins.forEach((mixin) => { // const composable = cache.get(mixin) ?? createComposableFromMixin(mixin) // Object.assign(context, composable()) // }) // } const context = {} as ToRefs & ToRefs> & M const reactiveContext = reactive(context) const vmContextProxy = createContextProxy(vm, reactiveContext) as VM beforeCreate && callHook(beforeCreate, instance, vm, 'bc') // methods if (methods) { for (const key in methods) { context[key] = methods[key].bind(vmContextProxy as any) } } //inject if (injectOptions) { resolveInjections(injectOptions, context) } // data if (dataFn) { // FIXME - any has to go here const data = dataFn.call(vmContextProxy as any, vmContextProxy as any) for (const key in data) { // FIXME - TS `this`error // @ts-expect-error this doesn't quite match yet context[key] = ref(data[key]) } } // computed if (computedOptions) { for (const key in computedOptions) { const def = computedOptions[key] const c = isFunction(def) ? computed(def.bind(vmContextProxy as any)) : computed({ get: def.get.bind(vmContextProxy), set: def.set.bind(vmContextProxy), }) // FIXME - TS `this`error // @ts-expect-error this doesn't quite match yet context[key] = c } } //watch if (watch) { for (const key in watch) { const def = watch[key] if (isArray(def)) { def.forEach((d) => createWatcher(d, vmContextProxy, key)) } else { createWatcher(def, vmContextProxy, key) } } } // provide if (provideOptions) { const provides = isFunction(provideOptions) ? provideOptions.call(vmContextProxy) : provideOptions Reflect.ownKeys(provides).forEach((key) => { provide(key, provides[key]) }) } // Lifecycle created && callHook(created, instance, vm, 'c') beforeMount && onBeforeMount(beforeMount.bind(vm)) mounted && onMounted(mounted.bind(vm)) beforeUpdate && onBeforeUpdate(beforeUpdate.bind(vm)) updated && onUpdated(updated.bind(vm)) activated && onActivated(activated.bind(vm)) deactivated && onDeactivated(deactivated.bind(vm)) beforeUnmount && onBeforeUnmount(beforeUnmount.bind(vm)) unmounted && onUnmounted(unmounted.bind(vm)) renderTracked && onRenderTracked(renderTracked.bind(vm)) renderTriggered && onRenderTriggered(renderTriggered.bind(vm)) errorCaptured && onErrorCaptured(errorCaptured.bind(vm)) return context } Object.assign(composable, { props, emits, }) cache.set(mixin, composable) return composable as typeof composable & { props: PropsOptions; emits: E } } function createWatcher( raw: ComponentWatchOptionItem, // ctx: Record, publicThis: ComponentPublicInstance & Record, key: string ) { const getter = key.includes('.') ? createPathGetter(publicThis, key) : () => (publicThis as any)[key] if (isString(raw)) { const handler = publicThis[raw] if (isFunction(handler)) { watch(getter, handler as WatchCallback) } } else if (isFunction(raw)) { watch(getter, raw.bind(publicThis)) } else if (isObject(raw)) { if (isArray(raw)) { raw.forEach((r) => createWatcher(r, publicThis, key)) } else { const handler = isFunction(raw.handler) ? raw.handler.bind(publicThis) : (publicThis[raw.handler] as WatchCallback) if (isFunction(handler)) { watch(getter, handler, raw) } } } } function createPathGetter(ctx: any, path: string) { const segments = path.split('.') return () => { let cur = ctx for (let i = 0; i < segments.length && cur; i++) { cur = cur[segments[i]] } return cur } } ================================================ FILE: src/defineMixin.ts ================================================ import type { ComputedOptions, MethodOptions, ComponentOptionsWithObjectProps, ComponentOptionsMixin, ComponentPropsOptions, EmitsOptions, } from 'vue' export /** #__PURE__*/ function defineMixin< PropsOptions extends Readonly, RawBindings, D, C extends ComputedOptions = {}, M extends MethodOptions = {}, Mixin extends ComponentOptionsMixin = ComponentOptionsMixin, Extends extends ComponentOptionsMixin = ComponentOptionsMixin, E extends EmitsOptions = {}, EE extends string = string >( options: ComponentOptionsWithObjectProps< PropsOptions, RawBindings, D, C, M, Mixin, Extends, E, EE > ): typeof options { return options } ================================================ FILE: src/env.d.ts ================================================ /// ================================================ FILE: src/index.ts ================================================ export { createComposableFromMixin } from './createComposable' export { defineMixin } from './defineMixin' ================================================ FILE: src/inject.ts ================================================ /** * @legal * This code is largely based on code from the Vue 3 codebase: * https://github.com/vuejs/core/blob/f67bb500b6071bc0e55a89709a495a27da73badd/packages/runtime-core/src/componentOptions.ts#L823-L876 * https://github.com/vuejs/core/blob/f67bb500b6071bc0e55a89709a495a27da73badd/packages/runtime-core/src/componentOptions.ts#L1073-L1084 */ import { inject, isRef, type ComponentPublicInstance, type DebuggerEvent, type Ref, } from 'vue' export type ComponentInjectOptions = string[] | ObjectInjectOptions export type ObjectInjectOptions = Record< string | symbol, string | symbol | { from?: string | symbol; default?: unknown } > export type DebuggerHook = (e: DebuggerEvent) => void export type ErrorCapturedHook = ( err: TError, instance: ComponentPublicInstance | null, info: string ) => boolean | void import { isArray, isObject } from './utils' export function resolveInjections( injectOptions: ComponentInjectOptions, ctx: any ) { if (isArray(injectOptions)) { injectOptions = normalizeInject(injectOptions)! } for (const key in injectOptions) { const opt = (injectOptions as ObjectInjectOptions)[key] let injected: unknown if (isObject(opt)) { if ('default' in opt) { injected = inject( opt.from || key, opt.default, true /* treat default function as factory */ ) } else { injected = inject(opt.from || key) } } else { injected = inject(opt) } if (isRef(injected)) { // TODO remove the check in 3.3 Object.defineProperty(ctx, key, { enumerable: true, configurable: true, get: () => (injected as Ref).value, set: (v) => ((injected as Ref).value = v), }) } else { ctx[key] = injected } } } function normalizeInject( raw: ComponentInjectOptions | undefined ): ObjectInjectOptions | undefined { if (!isArray(raw)) return raw const res: ObjectInjectOptions = {} for (let i = 0; i < raw.length; i++) { res[raw[i]] = raw[i] } return res } ================================================ FILE: src/typeTest.ts ================================================ /** * This file is not to be used anywhere, it's just to test the type inference */ import { defineComponent } from 'vue' import { createComposableFromMixin } from './createComposable' import { defineMixin } from './defineMixin' interface User { name: string street: string city: string hobbies: string[] } const mixin = defineMixin({ props: { show: { type: Boolean }, msg: { type: String, required: true }, }, emits: { // eslint-disable-next-line no-autofix/unused-imports/no-unused-vars 'update:show': (value?: any) => true, }, data: () => ({ msg: 'Hello World', age: 10, user: {} as User, }), computed: { birthYear(): number { return new Date().getFullYear() - this.age }, }, methods: { testFn(): void { console.log(this.user) }, }, } as const) const testComposable = createComposableFromMixin(mixin) /* eslint-disable no-autofix/unused-imports/no-unused-vars */ const Comp = defineComponent({ setup() { const state = testComposable() state.age state.msg state.user.value state.birthYear state.testFn() }, }) const props = testComposable.props const emits = testComposable.emits const CompWMixin = defineComponent({ mixins: [mixin], }) type TCompWMixin = InstanceType type checkAge = TCompWMixin['age'] type checkUser = TCompWMixin['user'] ================================================ FILE: src/types.ts ================================================ /** * @legal * Various types in this file are based on code from the Vue 3 codebase: * https://github.com/vuejs/core/blob/f67bb500b6071bc0e55a89709a495a27da73badd/packages/runtime-core/src/componentOptions.ts */ // TODO: replace with ComponentOptions or something import type { ComponentPublicInstance, ComputedGetter, EmitsOptions, ObjectEmitsOptions, WatchCallback, WatchOptions, WritableComputedOptions, } from 'vue' export type EmitsToProps = T extends string[] ? { [K in string & `on${Capitalize}`]?: (...args: any[]) => any } : T extends ObjectEmitsOptions ? { [K in string & `on${Capitalize}`]?: K extends `on${infer C}` ? T[Uncapitalize] extends null ? (...args: any[]) => any : ( ...args: T[Uncapitalize] extends (...args: infer P) => any ? P : never ) => any : never } : {} export interface MethodOptions { [key: string]: ( this: ComponentPublicInstance & Context, ...args: any[] ) => any } export type ComputedOptions = Record< string, ComputedGetter | WritableComputedOptions > export type ContextualizedComputedOptions = Record< string, | ((this: ComponentPublicInstance & Context, ...args: any[]) => any) | { get(): T set(v: T): void } > export type ExtractComputedReturns = { [key in keyof T]: T[key] extends { get: (...args: any[]) => infer TReturn } ? TReturn : T[key] extends (...args: any[]) => infer TReturn ? TReturn : never } export type ObjectWatchOptionItem = { handler: WatchCallback | string } & WatchOptions type WatchOptionItem = string | WatchCallback | ObjectWatchOptionItem export type ComponentWatchOptionItem = WatchOptionItem | WatchOptionItem[] ================================================ FILE: src/utils.ts ================================================ import { callWithAsyncErrorHandling, type ComponentInternalInstance, type ComponentPublicInstance, } from 'vue' export const isFunction = (val: unknown): val is Function => typeof val === 'function' export const isArray = Array.isArray export const isObject = (val: unknown): val is Record => val !== null && typeof val === 'object' export const isString = (val: unknown): val is string => typeof val === 'string' /** * @legal * taken from the Vue 3 Codebase, slightly adjusted: * https://github.com/vuejs/core/blob/f67bb500b6071bc0e55a89709a495a27da73badd/packages/runtime-core/src/componentOptions.ts#L878-L890 */ export function callHook( hook: Function, instance: ComponentInternalInstance, vm: ComponentPublicInstance, type: 'c' | 'bc' ) { callWithAsyncErrorHandling( isArray(hook) ? hook.map((h) => h.bind(vm)) : hook.bind(vm), instance, type as any ) } ================================================ FILE: src/vmContextProxy.ts ================================================ import type { ComponentPublicInstance } from 'vue' export /* #__PURE__ */ function createContextProxy( _vm: ComponentPublicInstance, context: Record ) { return new Proxy(_vm, { get(vm, key, receiver) { if (key in context) { return Reflect.get(context, key, receiver) } else { return (vm as any)[key] } }, set(vm, key, value, receiver) { if (key in context) { return Reflect.set(context, key, value, receiver) } else { return Reflect.set(vm, key, value) } }, has(vm, property) { return Reflect.has(context, property) || Reflect.has(vm, property) }, getOwnPropertyDescriptor(vm, property) { if (property in context) { return Reflect.getOwnPropertyDescriptor(context, property) } else { return Reflect.getOwnPropertyDescriptor(vm, property) } }, defineProperty(vm, property, descriptor) { return Reflect.defineProperty(vm, property, descriptor) }, deleteProperty(vm, property) { if (property in context) { return Reflect.deleteProperty(context, property) } else { return Reflect.deleteProperty(vm, property) } }, }) } ================================================ FILE: tsconfig.app.json ================================================ { "extends": "@vue/tsconfig/tsconfig.web.json", "include": ["src/env.d.tsd.ts", "src/**/*", "src/**/*.vue"], "exclude": ["src/**/__tests__/*"], "compilerOptions": { "composite": true, "outDir": "dist", "allowJs": true, "baseUrl": ".", "rootDir": "src", "paths": { "@/*": ["./src/*"] } } } ================================================ FILE: tsconfig.config.json ================================================ { "extends": "@vue/tsconfig/tsconfig.node.json", "include": ["vite.config.*", "vitest.config.*", "cypress.config.*", "playwright.config.*"], "compilerOptions": { "composite": true, "types": ["node"] } } ================================================ FILE: tsconfig.json ================================================ { "files": [], "references": [ { "path": "./tsconfig.config.json" }, { "path": "./tsconfig.app.json" }, { "path": "./tsconfig.vitest.json" } ] } ================================================ FILE: tsconfig.vitest.json ================================================ { "extends": "./tsconfig.app.json", "exclude": [], "compilerOptions": { "composite": true, "lib": [], "types": ["node", "jsdom"] } } ================================================ FILE: vite.config.ts ================================================ /// import { fileURLToPath, URL } from 'node:url' import { defineConfig } from 'vite' // https://vitejs.dev/config/ export default defineConfig({ resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)), }, }, build: { lib: { entry: fileURLToPath(new URL('./src/index.ts', import.meta.url)), name: 'VueComixable', formats: ['es', 'cjs', 'iife'], fileName: (format) => { switch (format) { case 'es': return 'index.mjs' case 'cjs': return 'index.cjs' case 'iife': return 'index.js' default: return 'index.js' } }, }, rollupOptions: { external: ['vue'], output: { globals: { vue: 'Vue', }, }, }, }, test: { setupFiles: ['./src/__tests__/setup.ts'], }, })