├── .editorconfig ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── component ├── errors.ts ├── index.d.ts ├── index.js ├── index.test.ts └── types.ts ├── composable ├── errors.ts ├── index.d.ts ├── index.js ├── index.test.ts └── types.ts ├── index.d.ts ├── index.js ├── inject ├── index.d.ts └── index.js ├── package.json ├── pnpm-lock.yaml ├── store ├── errors.ts ├── index.d.ts ├── index.js ├── index.test.ts └── types.ts ├── tsconfig.json └── utils ├── index.js └── index.test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | full: 7 | name: Node.js 20 Full 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout the repository 11 | uses: actions/checkout@v4 12 | 13 | - name: Install pnpm 14 | uses: pnpm/action-setup@v2 15 | with: 16 | version: latest 17 | 18 | - name: Install Node.js 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: 20 22 | cache: pnpm 23 | 24 | - name: Install dependencies 25 | run: pnpm install --frozen-lockfile --ignore-scripts 26 | 27 | - name: Run tests 28 | run: pnpm test 29 | short: 30 | runs-on: ubuntu-latest 31 | strategy: 32 | matrix: 33 | node-version: [18, 16] 34 | name: Node.js ${{ matrix.node-version }} Quick 35 | steps: 36 | - name: Checkout the repository 37 | uses: actions/checkout@v4 38 | 39 | - name: Install pnpm 40 | uses: pnpm/action-setup@v2 41 | with: 42 | version: latest 43 | 44 | - name: Install Node.js ${{ matrix.node-version }} 45 | uses: actions/setup-node@v4 46 | with: 47 | node-version: ${{ matrix.node-version }} 48 | cache: pnpm 49 | 50 | - name: Install dependencies 51 | run: pnpm install --frozen-lockfile --ignore-scripts 52 | 53 | - name: Run unit tests 54 | run: pnpm test 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | pnpm-lock.yaml 2 | tsconfig.json 3 | 4 | coverage/ 5 | test/ 6 | 7 | **/*.test.ts 8 | **/types.ts 9 | **/errors.ts 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | This project adheres to [Semantic Versioning](http://semver.org/). 3 | 4 | ## next 5 | * Drop NodeJS 12, 14 support 6 | * Update dependencies 7 | 8 | ## 0.10.3 9 | * Update dependencies 10 | 11 | ## 0.10.2 12 | * Update dependencies 13 | 14 | ## 0.10.1 15 | * Update Vue to 3.1.5 16 | * Update dependencies 17 | 18 | ## 0.10.0 19 | * Moved project to ESM-only type. Applications must use ESM too 20 | * Dropped Node.js 10 support 21 | * Fixed types performance by replacing type to interface 22 | * Simplified `useSubscription` API: `channels` cannot be function, but can be `computed`, `ref` 23 | * Moved `@logux/core` and `@logux/client` to peer dependencies 24 | * Removed `Client` and `CrossTabClient` from exports 25 | * Updated Vue to 3.0.11 26 | 27 | ## 0.9.24 28 | * Update Vue to 3.0.10 29 | 30 | ## 0.9.23 31 | * Update Vue to 3.0.9 32 | 33 | ## 0.9.22 34 | * Update Vue to 3.0.8 35 | 36 | ## 0.9.21 37 | * Fix bug that caused performance degradation related to `store.on('change', …)` 38 | * Update nanoevents to 5.1.13 39 | * Update dependencies 40 | 41 | ## 0.9.20 42 | * Update Vue to 3.0.7 43 | * Update nanoevents to 5.1.12 44 | * Update dependencies 45 | 46 | ## 0.9.19 47 | * Update dependencies 48 | 49 | ## 0.9.18 50 | * Update Vue to 3.0.5 51 | * Update Vuex to 4.0.0 52 | * Update dependencies 53 | * Refactor `useSubscription` for Vue 3.0.5 54 | 55 | ## 0.9.17 56 | * Add `debounce` for `useSubscription` to avoid flashing between channels 57 | * Update typings 58 | 59 | ## 0.9.16 60 | * Fix eternal `false` loading indication on `channels` changes 61 | * Update dependencies 62 | 63 | ## 0.9.15 64 | * Update Vue to 3.0.0 One Piece 65 | * Update dependencies 66 | 67 | ## 0.9.14 68 | * Update Vue to release candidate 13 69 | * Update dependencies 70 | 71 | ## 0.9.13 72 | * Update Vue to release candidate 12 73 | * Update dependencies 74 | 75 | ## 0.9.12 76 | * Rewrite tests with TypeScript 77 | * Extend `useStore` typings 78 | 79 | ## 0.9.11 80 | * Update Vue to release candidate 11 81 | * Update dependencies 82 | 83 | ## 0.9.10 84 | * Fix broken state after second logux/undo 85 | * Update Vue to release candidate 10 86 | * Update dependencies 87 | 88 | ## 0.9.9 89 | * Fix `useSubscription` subscribing on the same channels. 90 | 91 | ## 0.9.8 92 | * Fix `useSubscription` types, `options` argument can be undefined. 93 | 94 | ## 0.9.7 95 | * Add `store` option from `useSubscription` to support different store sources. 96 | 97 | ## 0.9.6 98 | * Update Vue to release candidate 9 99 | 100 | ## 0.9.5 101 | * Update Vue to release candidate 8 102 | 103 | ## 0.9.4 104 | * Update Vue to release candidate 7 105 | 106 | ## 0.9.3 107 | * Fix `useStore` types, add lost `key` argument 108 | * Update dependencies 109 | * Logux Client to 0.9.2 110 | * Vue to release candidate 6 111 | 112 | ## 0.9.2 113 | * `useSubscription`’s argument `channels` can be a getter function 114 | 115 | ## 0.9.1 116 | * Fix Typescript exports 117 | 118 | ## 0.9.0 119 | * Add Vue 3 support 120 | * Add `useSubscription` composable function 121 | * Add `useStore` shortcut from Vuex 122 | * Fix Typescript support 123 | * Update dependencies 124 | * Refactor helpers 125 | * Rename `loguxComponent` to `Subscribe` 126 | * Remove Vue 2 support 127 | * Remove `loguxMixin` mixin 128 | * Remove `LoguxVuex` plugin API 129 | * Remove `store.local`, `store.crossTab` and `store.sync` aliases 130 | 131 | ## 0.8.0 132 | * Add logux `commit` to vuex `action` context ([#31](https://github.com/logux/vuex/issues/31)) 133 | * Update dependencies 134 | 135 | ## 0.7.1 136 | * Fix native Vuex payload behavior 137 | 138 | ## 0.7.0 139 | * Add Vue plugin API 140 | ```js 141 | import Vue from 'vue' 142 | import { LoguxVuex, createLogux } from '@logux/vuex' 143 | 144 | Vue.use(LoguxVuex) 145 | ``` 146 | * Add new API 147 | * `this.$logux.local` 148 | * `this.$logux.crossTab` 149 | * `this.$logux.sync` 150 | * `store.commit.local`, `store.commit.crossTab`, `store.commit.sync` still available as alias to `this.$logux` 151 | * Fix native Vuex payload behavior 152 | * Refactor TypeScript support 153 | * Update dependencies 154 | 155 | ## 0.6.1 156 | * Fix component render with temporary no children 157 | 158 | ## 0.6.0 159 | * Add `tag` property for component to wrap multiple children and single text 160 | * Replace shared helpers 161 | * Update dependencies 162 | 163 | ## 0.5.1 164 | * Add ES modules support 165 | 166 | ## 0.5.0 167 | * Add full support of Vuex modules 168 | * Fix incorrect behavior with modules ([#26](https://github.com/logux/vuex/issues/26)) 169 | * Update dependencies 170 | 171 | ## 0.4.0 172 | * Add `loguxComponent`, component with scoped slots ([#18](https://github.com/logux/vuex/pull/18), [#25](https://github.com/logux/vuex/pull/25)) (by Stanislav Lashmanov) 173 | * Fix incorrect subscription after changing `channels` in `subscriptionMixin` 174 | * Fix incorrect TypeScript for mixin’s private methods 175 | * Rename mixin’s private methods 176 | * Rename mixin `subscriptionMixin` to `loguxMixin` 177 | * Rename folder `/subscription-mixin` to `/mixin` 178 | * Update dependencies 179 | 180 | ## 0.3.2 181 | * Update dependencies 182 | 183 | ## 0.3.1 184 | * Unify commit arguments 185 | * `commit.sync`, `commit.crossTab` & `commit.local` arguments can be either `(action, meta?)` or `(type, payload?, meta?)` 186 | * Fix dirty commit payload 187 | 188 | ## 0.3.0 189 | * Add TypeScript definitions (by Nikolay Govorov) 190 | * Add API docs via TypeDoc 191 | * Fix `commit.sync` return `ClientMeta` 192 | * Fix typo in mixin 193 | * Update to Logux Core 0.5 and Logux Client 0.8 194 | * Use WebSocket Protocol 3 195 | * Add `store.client.changeUser` 196 | * Add support for dynamic `token` 197 | * `userId` must be always a string without ":" 198 | * Rename `credentials` option to `token` 199 | * Move Vuex to peerDependencies. 200 | 201 | ## 0.2.0 202 | * Add `subscriptionMixin` mixin 203 | * Adds `isSubscribing` property to component 204 | * Rename `checkEvery` to `cleanEvery` 205 | * Mark package as side effect free 206 | 207 | ## 0.1.2 208 | * Fix possible bugs 209 | 210 | ## 0.1.1 211 | * Fix peerDependencies 212 | * Move to yarn from npm 213 | * More familiar API for Vue developers 214 | * `createLoguxStore` renamed to `createLogux` 215 | * `createLogux` return `{ Store }` 216 | 217 | ## 0.1.0 218 | * Initial release. 219 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright 2020 Eduard Aksamitov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Logux Vuex 2 | 3 | 5 | 6 | Logux is a new way to connect client and server. Instead of sending 7 | HTTP requests (e.g., AJAX and GraphQL) it synchronizes log of operations 8 | between client, server, and other clients. 9 | 10 | * **[Guide, recipes, and API](https://logux.org/)** 11 | * **[Issues](https://github.com/logux/logux/issues)** 12 | and **[roadmap](https://github.com/orgs/logux/projects/1)** 13 | * **[Projects](https://logux.org/guide/architecture/parts/)** 14 | inside Logux ecosystem 15 | 16 | This repository contains [Vuex] compatible API on top of the [Logux Client]. 17 | 18 | The current version is for Vue 3 and Vuex 4. 19 | For Vue 2 support, we have [0.8 version from a separate branch](https://github.com/logux/vuex/tree/0.8). 20 | 21 | [Vuex]: https://vuex.vuejs.org 22 | [Logux Client]: https://github.com/logux/client 23 | 24 | ## Install 25 | 26 | ```sh 27 | npm install @logux/core @logux/client @logux/vuex vuex@next 28 | ``` 29 | or 30 | ```sh 31 | yarn add @logux/core @logux/client @logux/vuex vuex@next 32 | ``` 33 | 34 | ## Usage 35 | 36 | See [documentation] for Logux API. 37 | 38 | [documentation]: https://github.com/logux/docs 39 | 40 | ```js 41 | import { CrossTabClient } from '@logux/client' 42 | import { createStoreCreator } from '@logux/vuex' 43 | 44 | const client = new CrossTabClient({ 45 | server: process.env.NODE_ENV === 'development' 46 | ? 'ws://localhost:31337' 47 | : 'wss://logux.example.com', 48 | subprotocol: '1.0.0', 49 | userId: 'anonymous', 50 | token: '' 51 | }) 52 | 53 | const createStore = createStoreCreator(client) 54 | 55 | const store = createStore({ 56 | state: {}, 57 | mutations: {}, 58 | actions: {}, 59 | modules: {} 60 | }) 61 | 62 | store.client.start() 63 | 64 | export default store 65 | ``` 66 | 67 | ## Subscription 68 | 69 | ### `useSubscription` 70 | 71 | Composable function that subscribes for channels during component initialization and unsubscribes on unmount. 72 | 73 | ```html 74 | 78 | 79 | 100 | ``` 101 | 102 | ### `Subscribe` 103 | 104 | Component-wrapper that subscribes for channels during component initialization and unsubscribes on unmount. 105 | 106 | ```html 107 | 113 | 114 | 135 | ``` 136 | 137 | ## Using with Typescript 138 | 139 | Place the following code in your project to allow this.$store to be typed correctly: 140 | 141 | ```ts 142 | // shims-vuex.d.ts 143 | 144 | import { LoguxVuexStore } from '@logux/vuex' 145 | 146 | declare module '@vue/runtime-core' { 147 | // Declare your own store states. 148 | interface State { 149 | count: number 150 | } 151 | 152 | interface ComponentCustomProperties { 153 | $store: LoguxVuexStore 154 | } 155 | } 156 | ``` 157 | -------------------------------------------------------------------------------- /component/errors.ts: -------------------------------------------------------------------------------- 1 | import { h, defineComponent } from 'vue' 2 | 3 | import { Subscribe } from '..' 4 | 5 | defineComponent({ 6 | setup() { 7 | return () => 8 | // THROWS No overload matches this call. 9 | h(Subscribe, { 10 | channels: 'users' 11 | }) 12 | } 13 | }) 14 | -------------------------------------------------------------------------------- /component/index.d.ts: -------------------------------------------------------------------------------- 1 | import { VNodeProps } from 'vue' 2 | 3 | import { Channels } from '../index.js' 4 | 5 | export interface SubscribeProps { 6 | channels: Channels 7 | } 8 | 9 | /** 10 | * Component-wrapper that subscribes 11 | * for channels during component initialization 12 | * and unsubscribes on unmount. 13 | * 14 | * It watches for `channels` changes 15 | * and `isSubscribing` indicates loading state. 16 | * 17 | * ```html 18 | * 24 | * 25 | * 46 | * ``` 47 | */ 48 | export const Subscribe: new () => { 49 | $props: VNodeProps & SubscribeProps 50 | } 51 | -------------------------------------------------------------------------------- /component/index.js: -------------------------------------------------------------------------------- 1 | import { defineComponent, toRefs } from 'vue' 2 | 3 | import { useSubscription } from '../composable/index.js' 4 | 5 | export let Subscribe = defineComponent({ 6 | name: 'LoguxSubscribe', 7 | props: { 8 | channels: { 9 | type: Array, 10 | required: true 11 | } 12 | }, 13 | setup(props, { slots }) { 14 | let { channels } = toRefs(props) 15 | 16 | let defaultSlot = slots.default 17 | 18 | if (!defaultSlot) { 19 | throw new Error('Provided scoped slot is empty') 20 | } 21 | 22 | let isSubscribing = useSubscription(channels) 23 | 24 | return () => defaultSlot({ isSubscribing }) 25 | } 26 | }) 27 | -------------------------------------------------------------------------------- /component/index.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ref, 3 | toRefs, 4 | reactive, 5 | nextTick, 6 | computed, 7 | defineComponent, 8 | ComponentPublicInstance 9 | } from 'vue' 10 | import { CrossTabClient, ClientMeta } from '@logux/client' 11 | import { mount, VueWrapper } from '@vue/test-utils' 12 | import { TestLog, TestTime } from '@logux/core' 13 | import { delay } from 'nanodelay' 14 | import { jest } from '@jest/globals' 15 | 16 | import { createStoreCreator, Subscribe } from '../index.js' 17 | 18 | interface ExtendedComponent extends VueWrapper { 19 | client?: any 20 | } 21 | 22 | function createComponent(component: any, options?: any): ExtendedComponent { 23 | let client = new CrossTabClient<{}, TestLog>({ 24 | server: 'wss://localhost:1337', 25 | subprotocol: '1.0.0', 26 | userId: '10', 27 | time: new TestTime() 28 | }) 29 | let createStore = createStoreCreator(client) 30 | let store = createStore({}) 31 | let wrapper: ExtendedComponent = mount(component, { 32 | ...options, 33 | global: { 34 | plugins: [store], 35 | components: { 36 | Subscribe, 37 | UserPhoto, 38 | SubscribeUserPhoto 39 | } 40 | } 41 | }) 42 | wrapper.client = store.client 43 | return wrapper 44 | } 45 | 46 | let UserPhoto = defineComponent({ 47 | name: 'UserPhoto', 48 | props: { 49 | id: { type: String, required: true }, 50 | isSubscribing: { type: Boolean, required: true } 51 | }, 52 | setup(props) { 53 | let { id, isSubscribing } = toRefs(props) 54 | let src = computed(() => `${id.value}.jpg`) 55 | return { 56 | src, 57 | isSubscribing 58 | } 59 | }, 60 | template: ` 61 | 62 | ` 63 | }) 64 | 65 | let SubscribeUserPhoto = defineComponent({ 66 | props: { 67 | id: { type: String, required: true } 68 | }, 69 | setup(props) { 70 | let { id } = toRefs(props) 71 | let channels = computed(() => { 72 | return [{ channel: `users/${id.value}`, fields: ['photo'] }] 73 | }) 74 | return { 75 | id, 76 | channels 77 | } 78 | }, 79 | template: ` 80 | 81 | 85 | 86 | ` 87 | }) 88 | 89 | it('throw empty scoped slot', () => { 90 | jest.spyOn(console, 'warn').mockImplementation(() => {}) 91 | jest.spyOn(console, 'error').mockImplementation(() => {}) 92 | 93 | expect(() => { 94 | createComponent(Subscribe, { 95 | props: { 96 | channels: ['users'] 97 | } 98 | }) 99 | }).toThrow('Provided scoped slot is empty') 100 | }) 101 | 102 | it('returns wrapped component', async () => { 103 | let component = createComponent(SubscribeUserPhoto, { 104 | props: { id: '1' } 105 | }) 106 | expect(component.findComponent({ name: 'UserPhoto' }).exists()).toBe(true) 107 | expect(component.html()).toBe('') 108 | }) 109 | 110 | it('subscribes', async () => { 111 | let SubscribeUser = defineComponent({ 112 | props: { 113 | id: { type: String, required: true } 114 | }, 115 | setup(props) { 116 | let { id } = toRefs(props) 117 | let channels = computed(() => { 118 | return [`users/${id.value}`] 119 | }) 120 | return { 121 | id, 122 | channels 123 | } 124 | }, 125 | template: ` 126 |
127 | ` 128 | }) 129 | let component = createComponent({ 130 | components: { SubscribeUser }, 131 | template: ` 132 | 133 | 134 | 135 | ` 136 | }) 137 | await nextTick() 138 | expect(component.client.log.actions()).toEqual([ 139 | { type: 'logux/subscribe', channel: 'users/1' }, 140 | { type: 'logux/subscribe', channel: 'users/2' } 141 | ]) 142 | }) 143 | 144 | it('subscribes by channel name', async () => { 145 | let SubscribeUser = { 146 | template: ` 147 |
148 | ` 149 | } 150 | let component = createComponent({ 151 | components: { SubscribeUser }, 152 | template: ` 153 | 154 | 155 | ` 156 | }) 157 | await delay(1) 158 | expect(component.client.log.actions()).toEqual([ 159 | { type: 'logux/subscribe', channel: 'users' } 160 | ]) 161 | }) 162 | 163 | it('unsubscribes', async () => { 164 | let UserList = { 165 | setup() { 166 | let state = reactive({ users: {} }) 167 | state.users = { a: '1', b: '1', c: '2' } 168 | 169 | function change(e: Event & { users: string }): void { 170 | state.users = e.users 171 | } 172 | 173 | return { 174 | ...toRefs(state), 175 | change 176 | } 177 | }, 178 | template: ` 179 |
180 | 185 |
186 | ` 187 | } 188 | 189 | let component = createComponent(UserList) 190 | let log = component.client.log 191 | 192 | expect(log.actions()).toEqual([ 193 | { type: 'logux/subscribe', channel: 'users/1', fields: ['photo'] }, 194 | { type: 'logux/subscribe', channel: 'users/2', fields: ['photo'] } 195 | ]) 196 | 197 | component.trigger('click', { users: { a: '1', c: '2' } }) 198 | await nextTick() 199 | expect(log.actions()).toEqual([ 200 | { type: 'logux/subscribe', channel: 'users/1', fields: ['photo'] }, 201 | { type: 'logux/subscribe', channel: 'users/2', fields: ['photo'] } 202 | ]) 203 | 204 | component.trigger('click', { users: { a: '1' } }) 205 | await nextTick() 206 | expect(log.actions()).toEqual([ 207 | { type: 'logux/subscribe', channel: 'users/1', fields: ['photo'] } 208 | ]) 209 | }) 210 | 211 | it('changes subscription', async () => { 212 | let component = createComponent({ 213 | setup() { 214 | let id = ref('1') 215 | 216 | function change({ id: newId }: { id: string }): void { 217 | id.value = newId 218 | } 219 | 220 | return { 221 | id, 222 | change 223 | } 224 | }, 225 | template: ` 226 |
227 | 228 |
229 | ` 230 | }) 231 | 232 | expect(component.client.log.actions()).toEqual([ 233 | { type: 'logux/subscribe', channel: 'users/1', fields: ['photo'] } 234 | ]) 235 | 236 | component.trigger('click', { id: '2' }) 237 | await nextTick() 238 | await delay(10) 239 | expect(component.client.log.actions()).toEqual([ 240 | { type: 'logux/subscribe', channel: 'users/1', fields: ['photo'] }, 241 | { type: 'logux/subscribe', channel: 'users/2', fields: ['photo'] } 242 | ]) 243 | }) 244 | 245 | it('does not resubscribe on non-relevant props changes', async () => { 246 | let component = createComponent({ 247 | setup() { 248 | let id = ref('1') 249 | 250 | function change(e: Event & { id: string }): void { 251 | id.value = e.id 252 | } 253 | 254 | return { 255 | id, 256 | change 257 | } 258 | }, 259 | template: ` 260 |
261 | 262 |
263 | ` 264 | }) 265 | 266 | let resubscriptions = 0 267 | component.client.log.on('add', () => { 268 | resubscriptions += 1 269 | }) 270 | 271 | component.trigger('click', { id: 2 }) 272 | await nextTick() 273 | expect(resubscriptions).toBe(0) 274 | }) 275 | 276 | it('supports multiple channels', async () => { 277 | let SubscribeUser = defineComponent({ 278 | props: { 279 | id: { type: String, required: true } 280 | }, 281 | setup(props) { 282 | let { id } = toRefs(props) 283 | let channels = computed(() => { 284 | return [`users/${id.value}`, `pictures/${id.value}`] 285 | }) 286 | return { channels } 287 | }, 288 | template: ` 289 |
290 | ` 291 | }) 292 | let component = createComponent({ 293 | components: { SubscribeUser }, 294 | template: ` 295 | 296 | 297 | 298 | ` 299 | }) 300 | expect(component.client.log.actions()).toEqual([ 301 | { type: 'logux/subscribe', channel: 'users/1' }, 302 | { type: 'logux/subscribe', channel: 'pictures/1' }, 303 | { type: 'logux/subscribe', channel: 'users/2' }, 304 | { type: 'logux/subscribe', channel: 'pictures/2' } 305 | ]) 306 | }) 307 | 308 | it('reports about subscription end', async () => { 309 | let component = createComponent({ 310 | setup() { 311 | let id = ref('1') 312 | 313 | function change(e: Event & { id: string }): void { 314 | id.value = e.id 315 | } 316 | 317 | return { 318 | id, 319 | change 320 | } 321 | }, 322 | template: ` 323 |
324 | 325 |
326 | ` 327 | }) 328 | 329 | let isSubscribing = (): string | undefined => 330 | component.find('img').attributes('issubscribing') 331 | let nodeId = component.client.nodeId 332 | let log = component.client.log 333 | 334 | await nextTick() 335 | expect(isSubscribing()).toBe('true') 336 | 337 | component.trigger('click', { id: '1' }) 338 | await nextTick() 339 | expect(isSubscribing()).toBe('true') 340 | 341 | component.trigger('click', { id: '2' }) 342 | await nextTick() 343 | expect(isSubscribing()).toBe('true') 344 | 345 | log.add({ type: 'logux/processed', id: `1 ${nodeId} 0` }) 346 | await delay(10) 347 | expect(isSubscribing()).toBe('true') 348 | 349 | log.add({ type: 'logux/processed', id: `3 ${nodeId} 0` }) 350 | await delay(10) 351 | expect(isSubscribing()).toBe('false') 352 | }) 353 | -------------------------------------------------------------------------------- /component/types.ts: -------------------------------------------------------------------------------- 1 | import { h, toRefs, computed, defineComponent } from 'vue' 2 | 3 | import { Subscribe } from '../index.js' 4 | 5 | defineComponent({ 6 | setup() { 7 | return () => 8 | h(Subscribe, { 9 | channels: ['users'] 10 | }) 11 | } 12 | }) 13 | 14 | defineComponent({ 15 | props: ['id'], 16 | setup(props) { 17 | let { id } = toRefs(props) 18 | 19 | return () => 20 | h(Subscribe, { 21 | channels: [ 22 | { channel: 'users' }, 23 | { channel: `users/${id}`, fields: ['name'] } 24 | ] 25 | }) 26 | } 27 | }) 28 | 29 | defineComponent({ 30 | props: ['id'], 31 | setup(props) { 32 | let { id } = toRefs(props) 33 | 34 | let channels = computed(() => { 35 | return [ 36 | { channel: 'users' }, 37 | { channel: `users/${id}`, fields: ['name'] } 38 | ] 39 | }) 40 | 41 | return () => 42 | h(Subscribe, { 43 | channels: channels.value 44 | }) 45 | } 46 | }) 47 | -------------------------------------------------------------------------------- /composable/errors.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent, computed } from 'vue' 2 | import { useStore } from 'vuex' 3 | 4 | import { useSubscription } from '..' 5 | 6 | defineComponent({ 7 | setup() { 8 | // THROWS Argument of type 'string' is not assignable to parameter of type 'Channels'. 9 | let isSubscribing = useSubscription('users') 10 | 11 | // THROWS Type 'number' is not assignable to type 'Channel'. 12 | useSubscription([1]) 13 | 14 | // THROWS Type 'number' is not assignable to type 'string'. 15 | useSubscription([{ channel: 1 }]) 16 | 17 | // THROWS Argument of type 'ComputedRef' is not assignable to parameter of type 'Channels'. 18 | useSubscription(computed(() => 'users')) 19 | 20 | let store = useStore() 21 | useSubscription( 22 | computed(() => ['users']), 23 | // THROWS Type 'Store' is missing the following properties 24 | { store } 25 | ) 26 | 27 | // THROWS Argument of type 'ComputedRef' is not assignable to parameter of type 'Channels'. 28 | useSubscription(computed(() => [1])) 29 | 30 | // THROWS Argument of type 'ComputedRef<{ channel: number; }[]>' is not assignable to parameter of type 'Channels'. 31 | useSubscription(computed(() => [{ channel: 1 }])) 32 | 33 | // THROWS This condition will always return 'false' since the types 'boolean' and 'string' have no overlap. 34 | if (isSubscribing.value === 'yes') { 35 | } 36 | } 37 | }) 38 | -------------------------------------------------------------------------------- /composable/index.d.ts: -------------------------------------------------------------------------------- 1 | import { Ref } from 'vue' 2 | 3 | import { LoguxVuexStore } from '../index.js' 4 | 5 | export type Channel = 6 | | string 7 | | { 8 | channel: string 9 | [key: string]: any 10 | } 11 | 12 | export type Channels = Channel[] | Ref 13 | 14 | export interface useSubscriptionOptions { 15 | /** 16 | * Logux Vuex store. 17 | */ 18 | store?: LoguxVuexStore 19 | /** 20 | * Delay in milliseconds to avoid returning `true` when switching between `channels`. 21 | */ 22 | debounce?: number 23 | } 24 | 25 | /** 26 | * Composable function that subscribes 27 | * for channels during component initialization 28 | * and unsubscribes on unmount. 29 | * 30 | * It watches for `channels` changes 31 | * and returns `isSubscribing` flag that indicates loading state. 32 | * 33 | * ```html 34 | * 38 | * 39 | * 58 | * 59 | * ``` 60 | * @param channels Channels to subscribe. 61 | * @param options Options. 62 | * @return `true` during data loading. 63 | */ 64 | export function useSubscription( 65 | channels: Channels, 66 | options?: useSubscriptionOptions 67 | ): Ref 68 | -------------------------------------------------------------------------------- /composable/index.js: -------------------------------------------------------------------------------- 1 | import { ref, isRef, watch, computed } from 'vue' 2 | import vuex from 'vuex' 3 | 4 | let { useStore } = vuex 5 | 6 | export function useSubscription(channels, options = {}) { 7 | let store = options.store || useStore() 8 | let debounce = options.debounce || 0 9 | let isSubscribing = ref(true) 10 | 11 | if (!isRef(channels)) { 12 | channels = ref(channels) 13 | } 14 | 15 | let subscriptions = computed(() => unifyChannelsObject(channels.value)) 16 | let id = computed(() => subscriptionsId(subscriptions.value)) 17 | 18 | watch( 19 | id, 20 | (newId, oldId, onInvalidate) => { 21 | let oldSubscriptions = subscriptions.value 22 | let ignoreResponse = false 23 | let timeout 24 | 25 | function resetTimeout() { 26 | clearTimeout(timeout) 27 | timeout = null 28 | } 29 | 30 | if (debounce > 0) { 31 | timeout = setTimeout(() => { 32 | isSubscribing.value = true 33 | }, debounce) 34 | } else { 35 | isSubscribing.value = true 36 | } 37 | 38 | subscribe(store, subscriptions.value).then(() => { 39 | if (timeout) resetTimeout(timeout) 40 | if (!ignoreResponse) { 41 | isSubscribing.value = false 42 | } 43 | }) 44 | 45 | onInvalidate(() => { 46 | ignoreResponse = true 47 | unsubscribe(store, oldSubscriptions) 48 | if (timeout) resetTimeout(timeout) 49 | }) 50 | }, 51 | { immediate: true } 52 | ) 53 | 54 | return isSubscribing 55 | } 56 | 57 | function unifyChannelsObject(channels) { 58 | return channels.map(i => { 59 | let subscription = typeof i === 'string' ? { channel: i } : i 60 | return [subscription, JSON.stringify(subscription)] 61 | }) 62 | } 63 | 64 | function subscriptionsId(subscriptions) { 65 | return subscriptions 66 | .map(i => i[1]) 67 | .sort() 68 | .join(' ') 69 | } 70 | 71 | function subscribe(store, subscriptions) { 72 | if (!store.subscriptions) store.subscriptions = {} 73 | if (!store.subscribers) store.subscribers = {} 74 | 75 | return Promise.all( 76 | subscriptions.map(i => { 77 | let subscription = i[0] 78 | let json = i[1] 79 | if (!store.subscribers[json]) store.subscribers[json] = 0 80 | store.subscribers[json] += 1 81 | if (store.subscribers[json] === 1) { 82 | let action = { ...subscription, type: 'logux/subscribe' } 83 | store.subscriptions[json] = store.commit.sync(action) 84 | } 85 | return store.subscriptions[json] 86 | }) 87 | ) 88 | } 89 | 90 | function unsubscribe(store, subscriptions) { 91 | subscriptions.forEach(i => { 92 | let subscription = i[0] 93 | let json = i[1] 94 | store.subscribers[json] -= 1 95 | if (store.subscribers[json] === 0) { 96 | let action = { ...subscription, type: 'logux/unsubscribe' } 97 | store.log.add(action, { sync: true }) 98 | delete store.subscriptions[json] 99 | } 100 | }) 101 | } 102 | -------------------------------------------------------------------------------- /composable/index.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | h, 3 | ref, 4 | toRefs, 5 | reactive, 6 | computed, 7 | nextTick, 8 | Fragment, 9 | defineComponent, 10 | ComponentPublicInstance 11 | } from 'vue' 12 | import { CrossTabClient, ClientMeta } from '@logux/client' 13 | import { mount, VueWrapper } from '@vue/test-utils' 14 | import { TestLog, TestTime } from '@logux/core' 15 | import { delay } from 'nanodelay' 16 | import { jest } from '@jest/globals' 17 | 18 | import { useStore, useSubscription, createStoreCreator } from '../index.js' 19 | 20 | interface ExtendedComponent extends VueWrapper { 21 | client?: any 22 | } 23 | 24 | function createComponent(component: any, options?: any): ExtendedComponent { 25 | let client = new CrossTabClient<{}, TestLog>({ 26 | server: 'wss://localhost:1337', 27 | subprotocol: '1.0.0', 28 | userId: '10', 29 | time: new TestTime() 30 | }) 31 | let createStore = createStoreCreator(client) 32 | let store = createStore({}) 33 | let wrapper: ExtendedComponent = mount(component, { 34 | ...options, 35 | global: { 36 | plugins: [store], 37 | components: { 38 | UserPhoto 39 | } 40 | } 41 | }) 42 | wrapper.client = store.client 43 | return wrapper 44 | } 45 | 46 | let UserPhoto = defineComponent({ 47 | props: { 48 | id: { type: String, required: true }, 49 | debounce: { type: Number, default: 0 } 50 | }, 51 | setup(props) { 52 | let { id, debounce } = toRefs(props) 53 | let src = computed(() => `${id.value}.jpg`) 54 | 55 | let isSubscribing = useSubscription( 56 | computed(() => { 57 | return [{ channel: `users/${id.value}`, fields: ['photo'] }] 58 | }), 59 | { debounce: debounce.value } 60 | ) 61 | 62 | return { 63 | src, 64 | isSubscribing 65 | } 66 | }, 67 | template: ` 68 | 69 | ` 70 | }) 71 | 72 | it('subscribes', async () => { 73 | let component = createComponent({ 74 | template: ` 75 |
76 | 77 | 78 | 79 |
80 | ` 81 | }) 82 | await delay(1) 83 | expect(component.client.log.actions()).toEqual([ 84 | { type: 'logux/subscribe', channel: 'users/1', fields: ['photo'] }, 85 | { type: 'logux/subscribe', channel: 'users/2', fields: ['photo'] } 86 | ]) 87 | }) 88 | 89 | it('accepts channel names', async () => { 90 | let User = defineComponent({ 91 | props: ['id'], 92 | setup({ id }) { 93 | useSubscription([`users/${id}`, `users/${id}/comments`]) 94 | return () => h('div') 95 | } 96 | }) 97 | let component = createComponent({ 98 | render() { 99 | return h('div', [h(User, { id: '1' })]) 100 | } 101 | }) 102 | await delay(1) 103 | expect(component.client.log.actions()).toEqual([ 104 | { type: 'logux/subscribe', channel: 'users/1' }, 105 | { type: 'logux/subscribe', channel: 'users/1/comments' } 106 | ]) 107 | }) 108 | 109 | it('unsubscribes', async () => { 110 | let UserList = defineComponent({ 111 | setup() { 112 | let state = reactive({ 113 | users: {} 114 | }) 115 | state.users = { a: '1', b: '1', c: '2' } 116 | 117 | function change(e: Event & { users: string }): void { 118 | state.users = e.users 119 | } 120 | 121 | return { 122 | ...toRefs(state), 123 | change 124 | } 125 | }, 126 | template: ` 127 |
128 | 133 |
134 | ` 135 | }) 136 | 137 | let component = createComponent(UserList) 138 | let log = component.client.log 139 | 140 | expect(log.actions()).toEqual([ 141 | { type: 'logux/subscribe', channel: 'users/1', fields: ['photo'] }, 142 | { type: 'logux/subscribe', channel: 'users/2', fields: ['photo'] } 143 | ]) 144 | 145 | component.trigger('click', { users: { a: '1', c: '2' } }) 146 | await nextTick() 147 | expect(log.actions()).toEqual([ 148 | { type: 'logux/subscribe', channel: 'users/1', fields: ['photo'] }, 149 | { type: 'logux/subscribe', channel: 'users/2', fields: ['photo'] } 150 | ]) 151 | 152 | component.trigger('click', { users: { a: '1' } }) 153 | await nextTick() 154 | expect(log.actions()).toEqual([ 155 | { type: 'logux/subscribe', channel: 'users/1', fields: ['photo'] } 156 | ]) 157 | }) 158 | 159 | it('changes subscription', async () => { 160 | let Profile = { 161 | setup() { 162 | let id = ref('1') 163 | 164 | function change(e: Event & { id: string }): void { 165 | id.value = e.id 166 | } 167 | 168 | return { 169 | id, 170 | change 171 | } 172 | }, 173 | template: ` 174 |
175 | 176 |
177 | ` 178 | } 179 | 180 | let component = createComponent(Profile) 181 | let log = component.client.log 182 | 183 | expect(log.actions()).toEqual([ 184 | { type: 'logux/subscribe', channel: 'users/1', fields: ['photo'] } 185 | ]) 186 | 187 | component.trigger('click', { id: '2' }) 188 | await nextTick() 189 | expect(log.actions()).toEqual([ 190 | { type: 'logux/subscribe', channel: 'users/1', fields: ['photo'] }, 191 | { type: 'logux/subscribe', channel: 'users/2', fields: ['photo'] } 192 | ]) 193 | }) 194 | 195 | it('does not resubscribe on non-relevant props changes', async () => { 196 | let component = createComponent({ 197 | setup() { 198 | let id = ref('1') 199 | 200 | function change(e: Event & { id: string }): void { 201 | id.value = e.id 202 | } 203 | 204 | return { 205 | id, 206 | change 207 | } 208 | }, 209 | template: ` 210 |
211 | 212 |
213 | ` 214 | }) 215 | 216 | let resubscriptions = 0 217 | component.client.log.on('add', () => { 218 | resubscriptions += 1 219 | }) 220 | 221 | component.trigger('click', { id: 2 }) 222 | await nextTick() 223 | expect(resubscriptions).toBe(0) 224 | }) 225 | 226 | it('reports about subscription end', async () => { 227 | let component = createComponent({ 228 | setup() { 229 | let id = ref('1') 230 | 231 | function change(e: Event & { id: string }): void { 232 | id.value = e.id 233 | } 234 | 235 | return { 236 | id, 237 | change 238 | } 239 | }, 240 | template: ` 241 |
242 | 243 |
244 | ` 245 | }) 246 | 247 | let isSubscribing = (): string | undefined => 248 | component.find('img').attributes('issubscribing') 249 | let nodeId = component.client.nodeId 250 | let log = component.client.log 251 | 252 | expect(isSubscribing()).toBe('true') 253 | 254 | component.trigger('click', { id: '1' }) 255 | await nextTick() 256 | expect(isSubscribing()).toBe('true') 257 | 258 | component.trigger('click', { id: '2' }) 259 | await nextTick() 260 | expect(isSubscribing()).toBe('true') 261 | 262 | log.add({ type: 'logux/processed', id: `1 ${nodeId} 0` }) 263 | await delay(10) 264 | expect(isSubscribing()).toBe('true') 265 | 266 | log.add({ type: 'logux/processed', id: `3 ${nodeId} 0` }) 267 | await delay(10) 268 | expect(isSubscribing()).toBe('false') 269 | 270 | component.trigger('click', { id: '3' }) 271 | await nextTick() 272 | expect(isSubscribing()).toBe('false') 273 | 274 | log.add({ type: 'logux/processed', id: `7 ${nodeId} 0` }) 275 | await delay(10) 276 | expect(isSubscribing()).toBe('false') 277 | 278 | component.trigger('click', { id: '4' }) 279 | await nextTick() 280 | expect(isSubscribing()).toBe('false') 281 | 282 | component.trigger('click', { id: '5' }) 283 | await nextTick() 284 | expect(isSubscribing()).toBe('false') 285 | 286 | await delay(250) 287 | expect(isSubscribing()).toBe('true') 288 | 289 | log.add({ type: 'logux/processed', id: `10 ${nodeId} 0` }) 290 | await delay(10) 291 | expect(isSubscribing()).toBe('true') 292 | 293 | log.add({ type: 'logux/processed', id: `12 ${nodeId} 0` }) 294 | await delay(10) 295 | expect(isSubscribing()).toBe('false') 296 | }) 297 | 298 | it('works on channels size changes', async () => { 299 | jest.spyOn(console, 'error') 300 | 301 | let UserList = defineComponent({ 302 | props: ['ids'], 303 | setup(props) { 304 | let { ids } = toRefs(props) 305 | 306 | let isSubscribing = useSubscription( 307 | computed(() => { 308 | if (typeof ids === 'undefined') return [] 309 | return ids.value.map((id: string) => `users/${id}`) 310 | }) 311 | ) 312 | 313 | return () => 314 | h('div', { 315 | isSubscribing: isSubscribing.value 316 | }) 317 | } 318 | }) 319 | 320 | let component = createComponent({ 321 | components: { UserList }, 322 | setup() { 323 | let ids = ref([1]) 324 | 325 | function change(e: Event & { ids: number[] }): void { 326 | ids.value = e.ids 327 | } 328 | 329 | return { 330 | ids, 331 | change 332 | } 333 | }, 334 | template: ` 335 |
336 | 337 |
338 | ` 339 | }) 340 | 341 | component.trigger('click', { ids: [1, 2] }) 342 | await nextTick() 343 | // eslint-disable-next-line no-console 344 | expect(console.error).not.toHaveBeenCalled() 345 | }) 346 | 347 | it('reports about subscription end with non-reactive channels', async () => { 348 | let User = defineComponent({ 349 | props: ['id'], 350 | setup({ id }) { 351 | let isSubscribing = useSubscription([`users/${id}`]) 352 | return () => 353 | h('li', { 354 | isSubscribing: isSubscribing.value 355 | }) 356 | } 357 | }) 358 | 359 | let List = defineComponent({ 360 | props: { 361 | ids: { type: Array, required: true } 362 | }, 363 | setup(props) { 364 | let { ids } = toRefs(props) 365 | return () => 366 | h( 367 | Fragment, 368 | ids.value.map(id => h(User, { id })) 369 | ) 370 | } 371 | }) 372 | 373 | let component = createComponent(List, { 374 | props: { 375 | ids: ['1', '2', '3'] 376 | } 377 | }) 378 | 379 | let isSubscribing = (): (string | undefined)[] => 380 | component.findAll('li').map(el => el.attributes('issubscribing')) 381 | let nodeId = component.client.nodeId 382 | let log = component.client.log 383 | 384 | expect(isSubscribing()).toEqual(['true', 'true', 'true']) 385 | 386 | log.add({ type: 'logux/processed', id: `2 ${nodeId} 0` }) 387 | await delay(10) 388 | expect(isSubscribing()).toEqual(['true', 'false', 'true']) 389 | 390 | await component.setProps({ ids: ['1', '2'] }) 391 | expect(isSubscribing()).toEqual(['true', 'false']) 392 | }) 393 | 394 | it('don’t resubscribe on the same channel', async () => { 395 | let List = defineComponent({ 396 | props: { 397 | ids: { type: Array, required: true } 398 | }, 399 | setup(props) { 400 | useSubscription(computed(() => props.ids.map(id => `users/${id}`))) 401 | return () => h('div') 402 | } 403 | }) 404 | 405 | let component = createComponent(List, { 406 | props: { 407 | ids: [0, 1, 2] 408 | } 409 | }) 410 | 411 | await component.setProps({ ids: [0, 1, 2] }) 412 | expect(component.client.log.actions()).toEqual([ 413 | { type: 'logux/subscribe', channel: 'users/0' }, 414 | { type: 'logux/subscribe', channel: 'users/1' }, 415 | { type: 'logux/subscribe', channel: 'users/2' } 416 | ]) 417 | }) 418 | 419 | it('supports different store sources', async () => { 420 | let component = createComponent({ 421 | setup() { 422 | let store = useStore() 423 | 424 | async function subscribe(): Promise { 425 | await nextTick() 426 | useSubscription( 427 | computed(() => ['users']), 428 | { store } 429 | ) 430 | } 431 | subscribe() 432 | 433 | return () => h('div') 434 | } 435 | }) 436 | 437 | await delay(10) 438 | expect(component.client.log.actions()).toEqual([ 439 | { type: 'logux/subscribe', channel: 'users' } 440 | ]) 441 | }) 442 | -------------------------------------------------------------------------------- /composable/types.ts: -------------------------------------------------------------------------------- 1 | import { toRefs, computed, defineComponent } from 'vue' 2 | 3 | import { useSubscription, useStore } from '../index.js' 4 | 5 | defineComponent({ 6 | setup() { 7 | useSubscription(['users']) 8 | 9 | useSubscription(computed(() => ['users'])) 10 | 11 | let store = useStore() 12 | useSubscription( 13 | computed(() => ['users']), 14 | { store } 15 | ) 16 | 17 | let channels = computed(() => ['users']) 18 | useSubscription(channels) 19 | } 20 | }) 21 | 22 | defineComponent({ 23 | props: ['id'], 24 | setup(props) { 25 | let { id } = toRefs(props) 26 | 27 | useSubscription([ 28 | { channel: 'users' }, 29 | { channel: `users/${id}`, fields: ['name'] } 30 | ]) 31 | 32 | let channels = computed(() => [ 33 | { channel: 'users' }, 34 | { channel: `users/${id}`, fields: ['name'] } 35 | ]) 36 | useSubscription(channels) 37 | } 38 | }) 39 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | export { Channel, Channels, useSubscription } from './composable/index.js' 2 | export { LoguxVuexStore, createStoreCreator } from './store/index.js' 3 | export { Subscribe } from './component/index.js' 4 | export { useStore } from './inject/index.js' 5 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | export { useStore } from './inject/index.js' 2 | export { Subscribe } from './component/index.js' 3 | export { useSubscription } from './composable/index.js' 4 | export { createStoreCreator } from './store/index.js' 5 | -------------------------------------------------------------------------------- /inject/index.d.ts: -------------------------------------------------------------------------------- 1 | import { Log } from '@logux/core' 2 | import { InjectionKey } from 'vue' 3 | import { Client, ClientMeta } from '@logux/client' 4 | 5 | import { LoguxVuexStore } from '../index.js' 6 | 7 | /** 8 | * Composable function that injects store into the component. 9 | * 10 | * ```js 11 | * import { useStore } from '@logux/vuex' 12 | * 13 | * export default { 14 | * setup () { 15 | * let store = useStore() 16 | * store.commit.sync('user/rename') 17 | * } 18 | * } 19 | * ``` 20 | * 21 | * @returns Store instance. 22 | */ 23 | export function useStore< 24 | S = any, 25 | L extends Log = Log, 26 | C extends Client = Client<{}, L> 27 | >( 28 | injectKey?: InjectionKey> | string 29 | ): LoguxVuexStore 30 | -------------------------------------------------------------------------------- /inject/index.js: -------------------------------------------------------------------------------- 1 | import vuex from 'vuex' 2 | 3 | let { useStore } = vuex 4 | 5 | export { useStore } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@logux/vuex", 3 | "version": "0.10.3", 4 | "description": "Vuex compatible API for Logux", 5 | "keywords": [ 6 | "logux", 7 | "client", 8 | "vuex", 9 | "websocket" 10 | ], 11 | "scripts": { 12 | "test:lint": "eslint .", 13 | "test:coverage": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --coverage", 14 | "test:types": "check-dts", 15 | "test:size": "size-limit", 16 | "test": "pnpm run /^test:/" 17 | }, 18 | "author": "Eduard Aksamitov ", 19 | "license": "MIT", 20 | "homepage": "https://logux.org/", 21 | "repository": "logux/vuex", 22 | "sideEffects": false, 23 | "type": "module", 24 | "types": "./index.d.ts", 25 | "exports": { 26 | ".": "./index.js", 27 | "./package.json": "./package.json" 28 | }, 29 | "engines": { 30 | "node": "^16.0.0 || ^18.0.0 || >=20.0.0" 31 | }, 32 | "peerDependencies": { 33 | "@logux/client": ">=0.10.0", 34 | "@logux/core": ">=0.7.0", 35 | "vue": ">=3.2.30", 36 | "vuex": ">=4.0.2" 37 | }, 38 | "dependencies": { 39 | "nanoevents": "^8.0.0" 40 | }, 41 | "devDependencies": { 42 | "@jest/globals": "^27.5.1", 43 | "@logux/actions": "^0.3.1", 44 | "@logux/client": "^0.18.4", 45 | "@logux/core": "^0.8.5", 46 | "@logux/eslint-config": "46.0.1", 47 | "@size-limit/preset-small-lib": "^7.0.8", 48 | "@types/jest": "^27.4.0", 49 | "@typescript-eslint/eslint-plugin": "^5.26.0", 50 | "@typescript-eslint/parser": "^5.26.0", 51 | "@vue/test-utils": "^2.4.1", 52 | "check-dts": "^0.7.2", 53 | "eslint": "^8.16.0", 54 | "eslint-config-standard": "^17.0.0", 55 | "eslint-plugin-import": "^2.26.0", 56 | "eslint-plugin-jest": "^26.2.2", 57 | "eslint-plugin-n": "^15.2.0", 58 | "eslint-plugin-node": "^11.1.0", 59 | "eslint-plugin-prefer-let": "^3.0.1", 60 | "eslint-plugin-promise": "^6.0.0", 61 | "eslint-plugin-security": "^1.5.0", 62 | "eslint-plugin-unicorn": "^42.0.0", 63 | "jest": "^27.5.1", 64 | "nano-staged": "^0.8.0", 65 | "nanodelay": "^2.0.2", 66 | "prettier": "^3.0.3", 67 | "simple-git-hooks": "^2.9.0", 68 | "size-limit": "^7.0.8", 69 | "ts-jest": "^28.0.3", 70 | "typescript": "^4.6.4", 71 | "vue": "^3.3.8", 72 | "vuex": "^4.1.0" 73 | }, 74 | "simple-git-hooks": { 75 | "pre-commit": "./node_modules/.bin/nano-staged" 76 | }, 77 | "nano-staged": { 78 | "*.{js,ts}": [ 79 | "prettier --write", 80 | "eslint --fix" 81 | ] 82 | }, 83 | "jest": { 84 | "preset": "ts-jest/presets/default-esm", 85 | "testEnvironment": "jsdom", 86 | "globals": { 87 | "ts-jest": { 88 | "useESM": true, 89 | "isolatedModules": true 90 | } 91 | }, 92 | "transformIgnorePatterns": [ 93 | "node_modules/(?!@logux)" 94 | ], 95 | "coverageThreshold": { 96 | "global": { 97 | "statements": 100 98 | } 99 | } 100 | }, 101 | "prettier": { 102 | "arrowParens": "avoid", 103 | "jsxSingleQuote": false, 104 | "quoteProps": "consistent", 105 | "semi": false, 106 | "singleQuote": true, 107 | "trailingComma": "none" 108 | }, 109 | "eslintConfig": { 110 | "extends": "@logux/eslint-config/esm", 111 | "rules": { 112 | "jest/no-conditional-expect": "off", 113 | "@typescript-eslint/no-var-requires": "off", 114 | "@typescript-eslint/no-explicit-any": "off" 115 | } 116 | }, 117 | "eslintIgnore": [ 118 | "**/errors.ts" 119 | ], 120 | "size-limit": [ 121 | { 122 | "name": "Client + Store", 123 | "import": { 124 | "@logux/client": "{ CrossTabClient }", 125 | "./index.js": "{ createStoreCreator }" 126 | }, 127 | "limit": "6 KB" 128 | }, 129 | { 130 | "name": "Client + Store + Helpers", 131 | "import": { 132 | "@logux/client": "{ CrossTabClient }", 133 | "./index.js": "{ createStoreCreator, useStore, useSubscription }" 134 | }, 135 | "limit": "7 KB" 136 | } 137 | ] 138 | } 139 | -------------------------------------------------------------------------------- /store/errors.ts: -------------------------------------------------------------------------------- 1 | import { CrossTabClient } from '@logux/client' 2 | 3 | import { createStoreCreator } from '..' 4 | 5 | interface RootState { 6 | value: number 7 | } 8 | 9 | let client = new CrossTabClient({ 10 | server: 'wss://localhost:1337', 11 | subprotocol: '1.0.0', 12 | userId: '10' 13 | }) 14 | 15 | let createStore = createStoreCreator(client) 16 | 17 | let store = createStore({ 18 | state: { 19 | value: 0 20 | }, 21 | mutations: { 22 | increment(state) { 23 | state.value = state.value + 1 24 | } 25 | } 26 | }) 27 | 28 | // THROWS Type 'number' is not assignable to type 'string[]'. 29 | store.commit.crossTab('increment', null, { reasons: 1 }) 30 | -------------------------------------------------------------------------------- /store/index.d.ts: -------------------------------------------------------------------------------- 1 | import { Unsubscribe } from 'nanoevents' 2 | import { Client, ClientMeta } from '@logux/client' 3 | import { Action, AnyAction, Log } from '@logux/core' 4 | import { 5 | CommitOptions, 6 | Store as VuexStore, 7 | Commit as VuexCommit, 8 | Payload as VuexPayload, 9 | Dispatch as VuexDispatch, 10 | StoreOptions as VuexStoreOptions, 11 | ActionContext as VuexActionContext 12 | } from 'vuex' 13 | 14 | export type LoguxVuexAction = AnyAction & VuexPayload 15 | 16 | export interface LoguxVuexCommit extends VuexCommit { 17 | (type: string, payload?: any, options?: CommitOptions): void 18 | (payloadWithType: A, options?: CommitOptions): void 19 | 20 | /** 21 | * Adds sync action to log and updates store state. 22 | * This action will be visible only for server and all browser tabs. 23 | * 24 | * ```js 25 | * store.commit.sync( 26 | * { type: 'CHANGE_NAME', name }, 27 | * { reasons: ['lastName'] } 28 | * ).then(meta => { 29 | * store.log.removeReason('lastName', { maxAdded: meta.added - 1 }) 30 | * }) 31 | * ``` 32 | * 33 | * @param type Action type. 34 | * @param payload Action’s payload. 35 | * @param meta Action’s metadata. 36 | * @returns Promise when action will be processed by the server. 37 | */ 38 | sync( 39 | type: string, 40 | payload?: any, 41 | meta?: Partial 42 | ): Promise 43 | /** 44 | * @param action Action. 45 | * @param meta Action’s metadata. 46 | */ 47 | sync( 48 | action: A, 49 | meta?: Partial 50 | ): Promise 51 | 52 | /** 53 | * Adds cross-tab action to log and updates store state. 54 | * This action will be visible only for all tabs. 55 | * 56 | * ```js 57 | * store.commit.crossTab( 58 | * { type: 'CHANGE_FAVICON', favicon }, 59 | * { reasons: ['lastFavicon'] } 60 | * ).then(meta => { 61 | * store.log.removeReason('lastFavicon', { maxAdded: meta.added - 1 }) 62 | * }) 63 | * ``` 64 | * 65 | * @param type Action type. 66 | * @param payload Action’s payload. 67 | * @param meta Action’s metadata. 68 | * @returns Promise when action will be processed by the server. 69 | */ 70 | crossTab( 71 | type: string, 72 | payload?: any, 73 | meta?: Partial 74 | ): Promise 75 | /** 76 | * @param action Action. 77 | * @param meta Action’s metadata. 78 | */ 79 | crossTab( 80 | action: A, 81 | meta?: Partial 82 | ): Promise 83 | 84 | /** 85 | * Adds local action to log and updates store state. 86 | * This action will be visible only for current tab. 87 | * 88 | * ```js 89 | * 90 | * store.commit.local( 91 | * { type: 'OPEN_MENU' }, 92 | * { reasons: ['lastMenu'] } 93 | * ).then(meta => { 94 | * store.log.removeReason('lastMenu', { maxAdded: meta.added - 1 }) 95 | * }) 96 | * ``` 97 | * 98 | * @param type Action type. 99 | * @param payload Action’s payload. 100 | * @param meta Action’s metadata. 101 | * @returns Promise when action will be processed by the server. 102 | */ 103 | local( 104 | type: string, 105 | payload?: any, 106 | meta?: Partial 107 | ): Promise 108 | /** 109 | * @param action Action. 110 | * @param meta Action’s metadata. 111 | */ 112 | local( 113 | action: A, 114 | meta?: Partial 115 | ): Promise 116 | } 117 | 118 | interface StateListener { 119 | ( 120 | state: S, 121 | prevState: S, 122 | action: A, 123 | meta: ClientMeta 124 | ): void 125 | } 126 | 127 | export interface LoguxVuexActionContext extends VuexActionContext { 128 | commit: LoguxVuexCommit 129 | } 130 | 131 | export type LoguxVuexActionHandler = ( 132 | this: LoguxVuexStore, 133 | injectee: LoguxVuexActionContext, 134 | payload?: any 135 | ) => any 136 | 137 | export interface LoguxVuexActionObject { 138 | root?: boolean 139 | handler: LoguxVuexActionHandler 140 | } 141 | 142 | export type LoguxVuexNativeAction = 143 | | LoguxVuexActionHandler 144 | | LoguxVuexActionObject 145 | 146 | export interface LoguxVuexActionTree { 147 | [key: string]: LoguxVuexNativeAction 148 | } 149 | 150 | export interface LoguxVuexStoreOptions 151 | extends Omit, 'actions'> { 152 | actions?: LoguxVuexActionTree 153 | } 154 | 155 | export class LoguxVuexStore< 156 | S = any, 157 | L extends Log = Log, 158 | C extends Client = Client<{}, L> 159 | > extends VuexStore { 160 | constructor(options: LoguxVuexStoreOptions) 161 | 162 | dispatch: VuexDispatch 163 | 164 | /** 165 | * Add action to log with Vuex compatible API. 166 | */ 167 | commit: LoguxVuexCommit 168 | 169 | /** 170 | * Subscribes for store events. Supported events: 171 | * 172 | * * `change`: when store was changed by action. 173 | * 174 | * ```js 175 | * store.on('change', (state, prevState, action, meta) => { 176 | * console.log(state, prevState, action, meta) 177 | * }) 178 | * ``` 179 | * 180 | * @param event The event name. 181 | * @param listener The listener function. 182 | * @returns Unbind listener from event. 183 | */ 184 | on(event: 'change', listener: StateListener): Unsubscribe 185 | 186 | /** 187 | * Logux synchronization client. 188 | */ 189 | client: C 190 | 191 | /** 192 | * The Logux log. 193 | */ 194 | log: L 195 | 196 | /** 197 | * Promise until loading the state from log store. 198 | */ 199 | initialize: Promise 200 | } 201 | 202 | export interface LoguxVuexOptions { 203 | /** 204 | * How many actions without `meta.reasons` will be kept for time travel. 205 | * Default is `1000`. 206 | */ 207 | reasonlessHistory?: number 208 | 209 | /** 210 | * How often save state to history. Default is `50`. 211 | */ 212 | saveStateEvery?: number 213 | 214 | /** 215 | * Callback when there is no history to replay actions accurate. 216 | */ 217 | onMissedHistory?: (action: Action) => void 218 | 219 | /** 220 | * How often we need to clean log from old actions. Default is every `25` 221 | * actions. 222 | */ 223 | cleanEvery?: number 224 | } 225 | 226 | /** 227 | * Vuex’s `createStore` function, compatible with Logux Client. 228 | * 229 | * @param options Vuex store options. 230 | * @returns Vuex store, compatible with Logux Client. 231 | */ 232 | export interface createStore< 233 | L extends Log = Log, 234 | C extends Client = Client<{}, L> 235 | > { 236 | (options: LoguxVuexStoreOptions): LoguxVuexStore 237 | } 238 | 239 | /** 240 | * Connects Logux client to Vuex’s `createStore` function. 241 | * 242 | * ```js 243 | * import { CrossTabClient } from '@logux/client' 244 | * import { createStoreCreator } from '@logux/vuex' 245 | * 246 | * const client = new CrossTabClient({ 247 | * server: process.env.NODE_ENV === 'development' 248 | * ? 'ws://localhost:31337' 249 | * : 'wss://logux.example.com', 250 | * subprotocol: '1.0.0', 251 | * userId: 'anonymous', 252 | * token: '' 253 | * }) 254 | * 255 | * const createStore = createStoreCreator(client) 256 | * 257 | * const store = createStore({ 258 | * state: {}, 259 | * mutations: {}, 260 | * actions: {}, 261 | * modules: {} 262 | * }) 263 | * 264 | * store.client.start() 265 | * ``` 266 | * 267 | * @param client Logux Client. 268 | * @param options Logux Vuex options. 269 | * @returns Vuex’s `createStore` function, compatible with Logux Client. 270 | */ 271 | export function createStoreCreator< 272 | L extends Log = Log, 273 | C extends Client = Client<{}, L> 274 | >(client: C, options?: LoguxVuexOptions): createStore 275 | -------------------------------------------------------------------------------- /store/index.js: -------------------------------------------------------------------------------- 1 | import vuex from 'vuex' 2 | import { createNanoEvents } from 'nanoevents' 3 | import { isFirstOlder } from '@logux/core' 4 | 5 | import { deepCopy, isPromise, forEachValue } from '../utils/index.js' 6 | 7 | let { createStore: createVuexStore } = vuex 8 | 9 | export function createStoreCreator(client, options = {}) { 10 | let reasonlessHistory = options.reasonlessHistory || 1000 11 | let onMissedHistory = options.onMissedHistory 12 | let saveStateEvery = options.saveStateEvery || 50 13 | let cleanEvery = options.cleanEvery || 25 14 | 15 | let log = client.log 16 | 17 | function createStore(vuexConfig) { 18 | let store = createVuexStore(deepCopy(vuexConfig)) 19 | 20 | store._actions = Object.create(null) 21 | store._pureMutations = Object.create(null) 22 | installModule(store, store._modules.root.state, [], store._modules.root) 23 | 24 | let emitter = createNanoEvents() 25 | 26 | let historyCleaned = false 27 | let stateHistory = {} 28 | 29 | let actionCount = 0 30 | function saveHistory(meta) { 31 | actionCount += 1 32 | if (saveStateEvery === 1 || actionCount % saveStateEvery === 1) { 33 | stateHistory[meta.id] = deepCopy(store.state) 34 | } 35 | } 36 | 37 | store.client = client 38 | store.log = log 39 | store.on = emitter.on.bind(emitter) 40 | 41 | let init 42 | store.initialize = new Promise(resolve => { 43 | init = resolve 44 | }) 45 | 46 | let prevMeta 47 | let storeCommit = store.commit 48 | 49 | function originCommit(action, opts) { 50 | if (action.type === 'logux/state') { 51 | store.replaceState(action.state) 52 | return 53 | } 54 | if (action.type in store._mutations) { 55 | if (hasSimplePayload(action)) { 56 | storeCommit(action.type, action.payload, opts) 57 | return 58 | } 59 | storeCommit(action, opts) 60 | } 61 | } 62 | 63 | store.commit = (type, payload, _options) => { 64 | let { action, options: commitOpts } = unifyCommitArgs( 65 | type, 66 | payload, 67 | _options 68 | ) 69 | let meta = { 70 | id: log.generateId(), 71 | tab: store.client.tabId, 72 | reasons: ['timeTravelTab' + store.client.tabId], 73 | commit: true 74 | } 75 | 76 | log.add(action, meta) 77 | prevMeta = meta 78 | let emit = 'change' in emitter.events 79 | let prevState = emit ? deepCopy(store.state) : undefined 80 | originCommit(action, commitOpts) 81 | emit && 82 | emitter.emit('change', deepCopy(store.state), prevState, action, meta) 83 | saveHistory(meta) 84 | } 85 | 86 | store.commit.local = (type, payload, _meta) => { 87 | let { action, meta } = unifyCommitArgs(type, payload, _meta) 88 | meta.tab = client.tabId 89 | if (meta.reasons || meta.keepLast) meta.noAutoReason = true 90 | return log.add(action, meta) 91 | } 92 | 93 | store.commit.crossTab = (type, payload, _meta) => { 94 | let { action, meta } = unifyCommitArgs(type, payload, _meta) 95 | if (meta.reasons || meta.keepLast) meta.noAutoReason = true 96 | return log.add(action, meta) 97 | } 98 | 99 | store.commit.sync = (type, payload, _meta) => { 100 | let { action, meta } = unifyCommitArgs(type, payload, _meta) 101 | if (meta.reasons || meta.keepLast) meta.noAutoReason = true 102 | return client.sync(action, meta) 103 | } 104 | 105 | function replaceState(state, actions, pushHistory) { 106 | let last = actions.length ? actions[actions.length - 1][1] : '' 107 | let newState = actions.reduceRight((prev, [action, id]) => { 108 | let changed = deepCopy(prev) 109 | 110 | let mutations = store._pureMutations 111 | if (action.type in mutations) { 112 | mutations[action.type].forEach(mutation => { 113 | let mutationState = changed 114 | if (mutation.path.length) { 115 | mutationState = mutation.path.reduce((obj, key) => { 116 | return obj[key] 117 | }, changed) 118 | } 119 | 120 | let mutationPayload = action 121 | if (hasSimplePayload(action)) { 122 | mutationPayload = action.payload 123 | } 124 | mutation.handler(mutationState, mutationPayload) 125 | }) 126 | } 127 | 128 | if (pushHistory && id === last) { 129 | stateHistory[pushHistory] = changed 130 | } else if (stateHistory[id]) { 131 | stateHistory[id] = changed 132 | } 133 | 134 | return changed 135 | }, state) 136 | originCommit({ type: 'logux/state', state: newState }) 137 | return newState 138 | } 139 | 140 | let replaying 141 | function replay(actionId) { 142 | let ignore = {} 143 | let actions = [] 144 | let replayed = false 145 | let newAction 146 | let collecting = true 147 | 148 | replaying = new Promise(resolve => { 149 | log 150 | .each((action, meta) => { 151 | if (meta.tab && meta.tab !== client.tabId) return true 152 | 153 | if (collecting || !stateHistory[meta.id]) { 154 | if (action.type === 'logux/undo') { 155 | ignore[action.id] = true 156 | return true 157 | } else if (action.type.startsWith('logux/')) { 158 | return true 159 | } 160 | 161 | if (!ignore[meta.id]) actions.push([action, meta.id]) 162 | if (meta.id === actionId) { 163 | newAction = action 164 | collecting = false 165 | } 166 | 167 | return true 168 | } else { 169 | replayed = true 170 | let stateFromHistory = deepCopy(stateHistory[meta.id]) 171 | replaceState(stateFromHistory, actions) 172 | return false 173 | } 174 | }) 175 | .then(() => { 176 | if (!replayed) { 177 | if (historyCleaned) { 178 | if (onMissedHistory) { 179 | onMissedHistory(newAction) 180 | } 181 | for (let i = actions.length - 1; i >= 0; i--) { 182 | let id = actions[i][1] 183 | if (stateHistory[id]) { 184 | replayed = true 185 | let stateFromHistory = deepCopy(stateHistory[id]) 186 | replaceState( 187 | stateFromHistory, 188 | actions.slice(0, i).concat([[newAction, actionId]]), 189 | id 190 | ) 191 | break 192 | } 193 | } 194 | } 195 | 196 | if (!replayed) { 197 | let state = collectState(deepCopy(vuexConfig)) 198 | replaceState(state, actions) 199 | } 200 | } 201 | 202 | replaying = false 203 | resolve() 204 | }) 205 | }) 206 | 207 | return replaying 208 | } 209 | 210 | log.on('preadd', (action, meta) => { 211 | let type = action.type 212 | let isLogux = type.startsWith('logux/') 213 | if (type === 'logux/undo') { 214 | meta.reasons.push('reasonsLoading') 215 | } 216 | if (!isLogux && !isFirstOlder(prevMeta, meta)) { 217 | meta.reasons.push('replay') 218 | } 219 | if (!isLogux && !meta.noAutoReason && !meta.commit) { 220 | meta.reasons.push('timeTravel') 221 | } 222 | }) 223 | 224 | let wait = {} 225 | 226 | async function process(action, meta) { 227 | if (replaying) { 228 | wait[meta.id] = true 229 | await replaying 230 | if (wait[meta.id]) { 231 | delete wait[meta.id] 232 | await process(action, meta) 233 | } 234 | return 235 | } 236 | 237 | if (action.type === 'logux/undo') { 238 | let [undoAction, undoMeta] = await log.byId(action.id) 239 | if (undoAction) { 240 | log.changeMeta(meta.id, { 241 | reasons: undoMeta.reasons.filter(i => i !== 'syncing') 242 | }) 243 | delete stateHistory[action.id] 244 | await replay(action.id) 245 | } else { 246 | await log.changeMeta(meta.id, { reasons: [] }) 247 | } 248 | } else if (!action.type.startsWith('logux/')) { 249 | if (isFirstOlder(prevMeta, meta)) { 250 | prevMeta = meta 251 | originCommit(action) 252 | if (meta.added) saveHistory(meta) 253 | } else { 254 | await replay(meta.id) 255 | if (meta.reasons.includes('replay')) { 256 | log.changeMeta(meta.id, { 257 | reasons: meta.reasons.filter(i => i !== 'replay') 258 | }) 259 | } 260 | } 261 | } 262 | } 263 | 264 | let lastAdded = 0 265 | let addCalls = 0 266 | client.on('add', (action, meta) => { 267 | if (meta.added > lastAdded) lastAdded = meta.added 268 | 269 | if (action.type !== 'logux/processed' && !meta.noAutoReason) { 270 | addCalls += 1 271 | if (addCalls % cleanEvery === 0 && lastAdded > reasonlessHistory) { 272 | historyCleaned = true 273 | log.removeReason('timeTravel', { 274 | maxAdded: lastAdded - reasonlessHistory 275 | }) 276 | log.removeReason('timeTravelTab' + store.client.tabId, { 277 | maxAdded: lastAdded - reasonlessHistory 278 | }) 279 | } 280 | } 281 | 282 | if (!meta.commit) { 283 | let emit = 'change' in emitter.events 284 | let prevState = emit ? deepCopy(store.state) : undefined 285 | process(action, meta).then(() => { 286 | if (emit) { 287 | let currentState = deepCopy(store.state) 288 | emitter.emit('change', currentState, prevState, action, meta) 289 | } 290 | }) 291 | } 292 | }) 293 | 294 | client.on('clean', (action, meta) => { 295 | delete wait[meta.id] 296 | delete stateHistory[meta.id] 297 | }) 298 | 299 | let previous = [] 300 | let ignores = {} 301 | log 302 | .each((action, meta) => { 303 | if (!meta.tab) { 304 | if (action.type === 'logux/undo') { 305 | ignores[action.id] = true 306 | } else if (!ignores[meta.id]) { 307 | previous.push([action, meta]) 308 | } 309 | } 310 | }) 311 | .then(() => { 312 | if (previous.length > 0) { 313 | Promise.all(previous.map(i => process(...i))).then(init) 314 | } else { 315 | init() 316 | } 317 | }) 318 | 319 | return store 320 | } 321 | 322 | return createStore 323 | } 324 | 325 | function installModule(store, rootState, path, module) { 326 | let namespace = store._modules.getNamespace(path) 327 | let local = modifyLocalContext(store, namespace, module.context) 328 | 329 | module.forEachMutation((mutation, key) => { 330 | let type = namespace + key 331 | let entry = store._pureMutations[type] || (store._pureMutations[type] = []) 332 | entry.push({ handler: mutation, path }) 333 | }) 334 | 335 | module.forEachAction((action, key) => { 336 | let type = action.root ? key : namespace + key 337 | let handler = action.handler || action 338 | registerAction(store, type, handler, local) 339 | }) 340 | 341 | module.forEachChild((child, key) => { 342 | installModule(store, rootState, path.concat(key), child) 343 | }) 344 | } 345 | 346 | function modifyLocalContext(store, namespace, context) { 347 | let noNamespace = namespace === '' 348 | 349 | context.commit = (_type, _payload, _options) => { 350 | let { action, options } = unifyCommitArgs(_type, _payload, _options) 351 | if (!noNamespace) { 352 | if (!options || !options.root) { 353 | action.type = namespace + action.type 354 | } 355 | } 356 | store.commit(action, options) 357 | } 358 | 359 | context.commit.sync = (_type, _payload, _meta) => { 360 | let { action, meta } = unifyCommitArgs(_type, _payload, _meta) 361 | action.type = noNamespace ? action.type : namespace + action.type 362 | return store.commit.sync(action, meta) 363 | } 364 | 365 | context.commit.local = (_type, _payload, _meta) => { 366 | let { action, meta } = unifyCommitArgs(_type, _payload, _meta) 367 | action.type = noNamespace ? action.type : namespace + action.type 368 | return store.commit.local(action, meta) 369 | } 370 | 371 | context.commit.crossTab = (_type, _payload, _meta) => { 372 | let { action, meta } = unifyCommitArgs(_type, _payload, _meta) 373 | action.type = noNamespace ? action.type : namespace + action.type 374 | return store.commit.crossTab(action, meta) 375 | } 376 | 377 | return context 378 | } 379 | 380 | function registerAction(store, type, handler, local) { 381 | let entry = store._actions[type] || (store._actions[type] = []) 382 | function wrappedActionHandler(payload) { 383 | let res = handler.call( 384 | store, 385 | { 386 | dispatch: local.dispatch, 387 | commit: local.commit, 388 | getters: local.getters, 389 | state: local.state, 390 | rootGetters: store.getters, 391 | rootState: store.state 392 | }, 393 | payload 394 | ) 395 | if (!isPromise(res)) { 396 | res = Promise.resolve(res) 397 | } 398 | if (store._devtoolHook) { 399 | return res.catch(err => { 400 | store._devtoolHook.emit('vuex:error', err) 401 | throw err 402 | }) 403 | } else { 404 | return res 405 | } 406 | } 407 | entry.push(wrappedActionHandler) 408 | } 409 | 410 | function hasSimplePayload(action) { 411 | return 'payload' in action && typeof action.payload !== 'object' 412 | } 413 | 414 | function unifyCommitArgs(type, payload = {}, options = {}) { 415 | let action 416 | let meta 417 | 418 | if (typeof type === 'object' && type.type) { 419 | action = type 420 | meta = payload 421 | } 422 | 423 | if (typeof type === 'string') { 424 | if (typeof payload === 'object') { 425 | action = { type, ...payload } 426 | } else { 427 | action = { type, payload } 428 | } 429 | meta = options 430 | } 431 | 432 | return { action, meta, options: meta } 433 | } 434 | 435 | function collectState(store) { 436 | let state = 437 | store.state && typeof store.state === 'function' 438 | ? store.state() 439 | : store.state || {} 440 | function collectModuleState(module, moduleName, moduleState) { 441 | if (moduleName) { 442 | moduleState[moduleName] = 443 | typeof module.state === 'function' ? module.state() : module.state 444 | } 445 | if (module.modules) { 446 | forEachValue(module.modules, (childModule, childModuleName) => { 447 | let childModuleState = moduleName 448 | ? moduleState[moduleName] 449 | : moduleState 450 | collectModuleState(childModule, childModuleName, childModuleState) 451 | }) 452 | } 453 | } 454 | collectModuleState(store, false, state) 455 | return state 456 | } 457 | -------------------------------------------------------------------------------- /store/index.test.ts: -------------------------------------------------------------------------------- 1 | import { CrossTabClient, ClientMeta, ClientOptions } from '@logux/client' 2 | import { Action, TestLog, TestPair, TestTime } from '@logux/core' 3 | import { ModuleTree, Mutation, MutationTree } from 'vuex' 4 | import { delay } from 'nanodelay' 5 | import { jest } from '@jest/globals' 6 | 7 | import { 8 | LoguxVuexOptions, 9 | LoguxVuexAction, 10 | LoguxVuexActionTree, 11 | LoguxVuexStore 12 | } from '../store/index.js' 13 | import { createStoreCreator } from '../index.js' 14 | 15 | interface State { 16 | value: number | string 17 | user?: { 18 | value: number | string 19 | } 20 | } 21 | 22 | function createClient( 23 | opts: Partial = {} 24 | ): CrossTabClient<{}, TestLog> { 25 | let client = new CrossTabClient<{}, TestLog>({ 26 | server: 'wss://localhost:1337', 27 | subprotocol: '1.0.0', 28 | userId: '10', 29 | time: new TestTime(), 30 | ...opts 31 | }) 32 | return client 33 | } 34 | 35 | function createStore( 36 | mutations: MutationTree, 37 | opts: Partial> = {}, 38 | modules: ModuleTree = {} 39 | ): LoguxVuexStore> { 40 | let creatorOptions = { 41 | reasonlessHistory: opts.reasonlessHistory, 42 | onMissedHistory: opts.onMissedHistory, 43 | saveStateEvery: opts.saveStateEvery, 44 | cleanEvery: opts.cleanEvery 45 | } 46 | 47 | delete opts.reasonlessHistory 48 | delete opts.onMissedHistory 49 | delete opts.saveStateEvery 50 | delete opts.cleanEvery 51 | 52 | let client = createClient(opts) 53 | let _createStore = createStoreCreator>( 54 | client, 55 | creatorOptions 56 | ) 57 | let store = _createStore({ state: { value: 0 }, mutations, modules }) 58 | return store 59 | } 60 | 61 | function increment(state: State): void { 62 | state.value = (state.value as number) + 1 63 | } 64 | 65 | function historyLine(state: State, payload: LoguxVuexAction): void { 66 | if (typeof payload === 'object') { 67 | state.value = `${state.value}${payload.value}` 68 | } else { 69 | state.value = `${state.value}${payload}` 70 | } 71 | } 72 | 73 | function emit(obj: any, event: string, ...args: any[]): void { 74 | obj.emitter.emit(event, ...args) 75 | } 76 | 77 | it('creates Vuex store', () => { 78 | let store = createStore({ increment }) 79 | store.commit({ type: 'increment' }) 80 | expect(store.state).toEqual({ value: 1 }) 81 | }) 82 | 83 | it('unify commit arguments', async () => { 84 | let store = createStore({ increment, historyLine }) 85 | store.commit('increment', 1) 86 | store.commit({ type: 'increment', value: 1 }) 87 | expect(store.state).toEqual({ value: 2 }) 88 | 89 | store.commit.sync('historyLine', 1, { reasons: ['test1'] }) 90 | store.commit.sync({ type: 'historyLine', value: 1 }, { reasons: ['test2'] }) 91 | await delay(10) 92 | let log = store.log.entries() 93 | expect(log[2][0]).toEqual({ type: 'historyLine', payload: 1 }) 94 | expect(log[2][1].sync).toBe(true) 95 | expect(log[2][1].reasons).toEqual(['test1', 'syncing']) 96 | expect(log[3][1].reasons).toEqual(['test2', 'syncing']) 97 | }) 98 | 99 | it('creates Logux client', () => { 100 | let store = createStore({ increment }) 101 | expect(store.client.options.subprotocol).toBe('1.0.0') 102 | }) 103 | 104 | it('not found mutation', () => { 105 | let store = createStore({ increment }) 106 | 107 | store.commit.crossTab({ type: 'mutation' }) 108 | store.commit('increment') 109 | store.commit('increment') 110 | store.commit({ type: 'logux/state', state: { value: 1 } }) 111 | expect(store.state).toEqual({ value: 1 }) 112 | }) 113 | 114 | it('commit mutation with prefixed name', async () => { 115 | let store = createStore({ 116 | 'utils/clean': state => { 117 | state.value = 0 118 | }, 119 | increment 120 | }) 121 | 122 | store.commit('increment') 123 | store.commit('increment') 124 | await store.commit.crossTab('increment') 125 | store.commit('utils/clean') 126 | expect(store.state.value).toBe(0) 127 | }) 128 | 129 | it('commit from action context', () => { 130 | let client = createClient() 131 | let _createStore = createStoreCreator>(client) 132 | let mutations = { increment } 133 | let actions: LoguxVuexActionTree = { 134 | INC({ commit }) { 135 | commit('increment') 136 | commit.local('increment') 137 | commit.sync('increment') 138 | commit.crossTab('increment') 139 | } 140 | } 141 | let store = _createStore({ 142 | state: { value: 0 }, 143 | mutations, 144 | actions, 145 | modules: { 146 | A: { 147 | namespaced: true, 148 | state: { value: 0 }, 149 | mutations, 150 | actions: { 151 | ...actions, 152 | ROOT_INC: { 153 | root: true, 154 | handler({ commit }) { 155 | commit('increment') 156 | } 157 | } 158 | } 159 | } 160 | } 161 | }) 162 | 163 | store.dispatch('INC') 164 | store.dispatch('ROOT_INC') 165 | store.dispatch('A/INC') 166 | 167 | expect(store.state).toEqual({ value: 1, A: { value: 2 } }) 168 | expect(store.log.entries()).toHaveLength(9) 169 | }) 170 | 171 | // https://github.com/vuejs/vuex/blob/dev/test/unit/store.spec.js#L164 172 | it('vuex: detecting action Promise errors', () => { 173 | let client = createClient() 174 | let _createStore = createStoreCreator(client) 175 | let error = new Error('no') 176 | let store = _createStore({ 177 | actions: { 178 | TEST() { 179 | return Promise.reject(error) 180 | } 181 | } 182 | }) 183 | let spy = jest.fn() 184 | // @ts-ignore 185 | store._devtoolHook = { 186 | emit: spy 187 | } 188 | let thenSpy = jest.fn() 189 | store 190 | .dispatch('TEST') 191 | .then(thenSpy) 192 | .catch(err => { 193 | expect(thenSpy).not.toHaveBeenCalled() 194 | expect(err).toBe(error) 195 | expect(spy).toHaveBeenCalledWith('vuex:error', error) 196 | }) 197 | }) 198 | 199 | it('commit root mutation in namespaced module', () => { 200 | let client = createClient() 201 | let _createStore = createStoreCreator>(client) 202 | let store = _createStore({ 203 | state: { value: 0 }, 204 | mutations: { increment }, 205 | modules: { 206 | user: { 207 | namespaced: true, 208 | state: { value: 0 }, 209 | mutations: { increment }, 210 | actions: { 211 | someAction({ commit }) { 212 | commit('increment') 213 | commit('increment', null, { root: true }) 214 | } 215 | } 216 | } 217 | } 218 | }) 219 | 220 | store.dispatch('user/someAction') 221 | expect(store.state).toEqual({ value: 1, user: { value: 1 } }) 222 | expect(store.log.actions()).toEqual([ 223 | { type: 'user/increment' }, 224 | { type: 'increment' } 225 | ]) 226 | }) 227 | 228 | it('sets tab ID', async () => { 229 | let store = createStore({ increment }) 230 | 231 | await new Promise(resolve => { 232 | store.log.on('add', (action, meta) => { 233 | expect(meta.tab).toEqual(store.client.tabId) 234 | expect(meta.reasons).toEqual([`timeTravelTab${store.client.tabId}`]) 235 | resolve() 236 | }) 237 | store.commit({ type: 'increment' }) 238 | }) 239 | }) 240 | 241 | it('has shortcut for add', async () => { 242 | let store = createStore({ increment }) 243 | 244 | await store.commit.crossTab({ type: 'increment' }, { reasons: ['test'] }) 245 | expect(store.state).toEqual({ value: 1 }) 246 | expect(store.log.entries()[0][1].reasons).toEqual(['test']) 247 | }) 248 | 249 | it('listen for action from other tabs', () => { 250 | let store = createStore({ increment }) 251 | emit(store.client, 'add', { type: 'increment' }, { id: '1 t 0' }) 252 | expect(store.state).toEqual({ value: 1 }) 253 | }) 254 | 255 | it('undoes last when snapshot exists', async () => { 256 | let store = createStore({ historyLine }, { saveStateEvery: 1 }) 257 | 258 | await store.commit.crossTab( 259 | { type: 'historyLine', value: 'a' }, 260 | { 261 | id: '57 106:test1 1', 262 | reasons: ['test'] 263 | } 264 | ) 265 | await store.commit.crossTab( 266 | { type: 'historyLine', value: 'a' }, 267 | { 268 | id: '58 106:test1 1', 269 | reasons: ['test'] 270 | } 271 | ) 272 | await store.commit.crossTab( 273 | { 274 | type: 'logux/undo', 275 | id: '58 106:test1 1', 276 | reason: 'test undo', 277 | action: { type: '???' } 278 | }, 279 | { 280 | id: '59 106:test1 1', 281 | reasons: ['as requested'] 282 | } 283 | ) 284 | await delay(10) 285 | expect(store.state.value).toBe('0a') 286 | }) 287 | 288 | it('saves previous states', async () => { 289 | let calls = 0 290 | let store = createStore({ 291 | A() { 292 | calls += 1 293 | } 294 | }) 295 | 296 | let promise: Promise = Promise.resolve() 297 | for (let i = 0; i < 60; i++) { 298 | if (i % 2 === 0) { 299 | promise = promise.then(() => { 300 | return store.commit.crossTab({ type: 'A' }, { reasons: ['test'] }) 301 | }) 302 | } else { 303 | store.commit({ type: 'A' }) 304 | } 305 | } 306 | 307 | await promise 308 | expect(calls).toBe(60) 309 | calls = 0 310 | await store.commit.crossTab( 311 | { type: 'A' }, 312 | { id: '57 10:test1 1', reasons: ['test'] } 313 | ) 314 | expect(calls).toBe(10) 315 | }) 316 | 317 | it('changes history recording frequency', async () => { 318 | let calls = 0 319 | let store = createStore( 320 | { 321 | A() { 322 | calls += 1 323 | } 324 | }, 325 | { 326 | saveStateEvery: 1 327 | } 328 | ) 329 | 330 | await Promise.all([ 331 | store.commit.crossTab({ type: 'A' }, { reasons: ['test'] }), 332 | store.commit.crossTab({ type: 'A' }, { reasons: ['test'] }), 333 | store.commit.crossTab({ type: 'A' }, { reasons: ['test'] }), 334 | store.commit.crossTab({ type: 'A' }, { reasons: ['test'] }) 335 | ]) 336 | calls = 0 337 | await store.commit.crossTab( 338 | { type: 'A' }, 339 | { id: '3 10:test1 1', reasons: ['test'] } 340 | ) 341 | expect(calls).toBe(2) 342 | }) 343 | 344 | it('cleans its history on removing action', async () => { 345 | let calls = 0 346 | let store = createStore( 347 | { 348 | A() { 349 | calls += 1 350 | } 351 | }, 352 | { 353 | saveStateEvery: 2 354 | } 355 | ) 356 | let nodeId = store.client.nodeId 357 | 358 | await Promise.all([ 359 | store.commit.crossTab({ type: 'A' }, { reasons: ['test'] }), 360 | store.commit.crossTab({ type: 'A' }, { reasons: ['test'] }), 361 | store.commit.crossTab({ type: 'A' }, { reasons: ['test'] }), 362 | store.commit.crossTab({ type: 'A' }, { reasons: ['test'] }), 363 | store.commit.crossTab({ type: 'A' }, { reasons: ['test'] }), 364 | store.commit.crossTab({ type: 'A' }, { reasons: ['test'] }) 365 | ]) 366 | await store.log.changeMeta(`5 ${nodeId} 0`, { reasons: [] }) 367 | calls = 0 368 | await store.commit.crossTab( 369 | { type: 'A' }, 370 | { id: `5 ${nodeId} 1`, reasons: ['test'] } 371 | ) 372 | expect(calls).toBe(3) 373 | }) 374 | 375 | it('changes history', async () => { 376 | let store = createStore({ historyLine }) 377 | 378 | await Promise.all([ 379 | store.commit.crossTab( 380 | { type: 'historyLine', value: 'a' }, 381 | { reasons: ['test'] } 382 | ), 383 | store.commit.crossTab( 384 | { type: 'historyLine', value: 'b' }, 385 | { reasons: ['test'] } 386 | ) 387 | ]) 388 | store.commit({ type: 'historyLine', value: 'c' }) 389 | store.commit({ type: 'historyLine', value: 'd' }) 390 | await store.commit.crossTab( 391 | { type: 'historyLine', value: '|' }, 392 | { id: '2 10:test1 1', reasons: ['test'] } 393 | ) 394 | expect(store.state.value).toBe('0ab|cd') 395 | }) 396 | 397 | it('undoes actions', async () => { 398 | let store = createStore( 399 | { historyLine }, 400 | { 401 | saveStateEvery: 1 402 | } 403 | ) 404 | let nodeId = store.client.nodeId 405 | 406 | await Promise.all([ 407 | store.commit.crossTab( 408 | { type: 'historyLine', value: 'a' }, 409 | { reasons: ['test'] } 410 | ), 411 | store.commit.crossTab( 412 | { type: 'historyLine', value: 'b' }, 413 | { reasons: ['test'] } 414 | ), 415 | store.commit.crossTab( 416 | { type: 'historyLine', value: 'c' }, 417 | { reasons: ['test'] } 418 | ) 419 | ]) 420 | expect(store.state.value).toBe('0abc') 421 | 422 | await store.commit.crossTab( 423 | { type: 'logux/undo', id: `3 ${nodeId} 0` }, 424 | { reasons: ['test'] } 425 | ) 426 | await delay(1) 427 | expect(store.state.value).toBe('0ab') 428 | 429 | await store.commit.crossTab( 430 | { type: 'historyLine', value: 'd' }, 431 | { reasons: ['test'] } 432 | ) 433 | expect(store.state.value).toBe('0abd') 434 | 435 | await store.commit.crossTab( 436 | { type: 'logux/undo', id: `5 ${nodeId} 0` }, 437 | { reasons: ['test'] } 438 | ) 439 | await delay(1) 440 | expect(store.state.value).toBe('0ab') 441 | }) 442 | 443 | it('ignores cleaned history from non-legacy actions', async () => { 444 | let onMissedHistory = jest.fn() 445 | let store = createStore( 446 | { historyLine }, 447 | { 448 | onMissedHistory, 449 | saveStateEvery: 2 450 | } 451 | ) 452 | 453 | await Promise.all([ 454 | store.commit.crossTab( 455 | { type: 'historyLine', value: 'a' }, 456 | { reasons: ['one'] } 457 | ), 458 | store.commit.crossTab( 459 | { type: 'historyLine', value: 'b' }, 460 | { reasons: ['test'] } 461 | ), 462 | store.commit.crossTab( 463 | { type: 'historyLine', value: 'c' }, 464 | { reasons: ['test'] } 465 | ), 466 | store.commit.crossTab( 467 | { type: 'historyLine', value: 'd' }, 468 | { reasons: ['test'] } 469 | ) 470 | ]) 471 | await store.log.removeReason('one') 472 | store.commit.crossTab( 473 | { type: 'historyLine', value: '|' }, 474 | { id: '1 10:test1 0', reasons: ['test'] } 475 | ) 476 | await delay(1) 477 | expect(store.state.value).toBe('0|bcd') 478 | expect(onMissedHistory).not.toHaveBeenCalledWith() 479 | }) 480 | 481 | it('does not replays actions on logux/ actions', async () => { 482 | let commited: string[] = [] 483 | let saveCommited: Mutation = (state, action) => 484 | commited.push(action.type) 485 | let store = createStore({ 486 | 'A': saveCommited, 487 | 'B': saveCommited, 488 | 'logux/processed': saveCommited, 489 | 'logux/subscribe': saveCommited, 490 | 'logux/unsubscribe': saveCommited 491 | }) 492 | 493 | store.log.add({ type: 'A' }, { reasons: ['t'] }) 494 | store.log.add({ type: 'logux/processed' }, { time: 0 }) 495 | store.log.add({ type: 'logux/subscribe' }, { sync: true, time: 0 }) 496 | store.log.add({ type: 'logux/unsubscribe' }, { sync: true, time: 0 }) 497 | store.log.add({ type: 'B' }, { reasons: ['t'], time: 0 }) 498 | await delay(1) 499 | expect(commited).toEqual(['A', 'B', 'A']) 500 | expect(store.log.actions()).toEqual([ 501 | { type: 'logux/subscribe' }, 502 | { type: 'B' }, 503 | { type: 'A' } 504 | ]) 505 | }) 506 | 507 | it('replays history for reason-less action', async () => { 508 | let store = createStore({ historyLine }) 509 | 510 | await Promise.all([ 511 | store.commit.crossTab( 512 | { type: 'historyLine', value: 'a' }, 513 | { reasons: ['test'] } 514 | ), 515 | store.commit.crossTab( 516 | { type: 'historyLine', value: 'b' }, 517 | { reasons: ['test'] } 518 | ), 519 | store.commit.crossTab( 520 | { type: 'historyLine', value: 'c' }, 521 | { reasons: ['test'] } 522 | ) 523 | ]) 524 | store.commit.crossTab( 525 | { type: 'historyLine', value: '|' }, 526 | { id: '1 10:test1 1', noAutoReason: true } 527 | ) 528 | await delay(1) 529 | expect(store.state.value).toBe('0a|bc') 530 | expect(store.log.entries()).toHaveLength(3) 531 | }) 532 | 533 | it('replays actions before staring since initial state', async () => { 534 | let onMissedHistory = jest.fn() 535 | let store = createStore( 536 | { historyLine }, 537 | { 538 | onMissedHistory, 539 | saveStateEvery: 2 540 | } 541 | ) 542 | 543 | await Promise.all([ 544 | store.commit.crossTab( 545 | { type: 'historyLine', value: 'b' }, 546 | { reasons: ['test'] } 547 | ), 548 | store.commit.crossTab( 549 | { type: 'historyLine', value: 'c' }, 550 | { reasons: ['test'] } 551 | ), 552 | store.commit.crossTab( 553 | { type: 'historyLine', value: 'd' }, 554 | { reasons: ['test'] } 555 | ) 556 | ]) 557 | store.commit.crossTab( 558 | { type: 'historyLine', value: '|' }, 559 | { id: '0 10:test1 0', reasons: ['test'] } 560 | ) 561 | await delay(1) 562 | expect(onMissedHistory).not.toHaveBeenCalled() 563 | expect(store.state.value).toBe('0|bcd') 564 | }) 565 | 566 | it('replays actions on missed history', async () => { 567 | let onMissedHistory = jest.fn() 568 | let store = createStore( 569 | { historyLine }, 570 | { 571 | reasonlessHistory: 2, 572 | onMissedHistory, 573 | saveStateEvery: 2, 574 | cleanEvery: 1 575 | } 576 | ) 577 | 578 | store.commit({ type: 'historyLine', value: 'a' }) 579 | store.commit({ type: 'historyLine', value: 'b' }) 580 | store.commit({ type: 'historyLine', value: 'c' }) 581 | store.commit({ type: 'historyLine', value: 'd' }) 582 | await delay(1) 583 | store.commit.crossTab( 584 | { type: 'historyLine', value: '[' }, 585 | { id: '0 10:test1 0', reasons: ['test'] } 586 | ) 587 | await delay(1) 588 | expect(store.state.value).toBe('0abc[d') 589 | expect(onMissedHistory).toHaveBeenCalledWith({ 590 | type: 'historyLine', 591 | value: '[' 592 | }) 593 | store.commit.crossTab( 594 | { type: 'historyLine', value: ']' }, 595 | { id: '0 10:test1 1', reasons: ['test'] } 596 | ) 597 | await delay(1) 598 | expect(store.state.value).toBe('0abc[]d') 599 | }) 600 | 601 | it('works without onMissedHistory', async () => { 602 | let store = createStore( 603 | { historyLine }, 604 | { 605 | reasonlessHistory: 2, 606 | saveStateEvery: 2, 607 | cleanEvery: 1 608 | } 609 | ) 610 | store.commit({ type: 'ADD', value: 'a' }) 611 | store.commit({ type: 'ADD', value: 'b' }) 612 | store.commit({ type: 'ADD', value: 'c' }) 613 | store.commit({ type: 'ADD', value: 'd' }) 614 | await delay(1) 615 | await store.commit.crossTab( 616 | { type: 'ADD', value: '|' }, 617 | { id: '0 10:test1 0', reasons: ['test'] } 618 | ) 619 | }) 620 | 621 | it('does not fall on missed onMissedHistory', async () => { 622 | let store = createStore({ historyLine }) 623 | 624 | await store.commit.crossTab( 625 | { type: 'historyLine', value: 'a' }, 626 | { reasons: ['first'] } 627 | ) 628 | await store.log.removeReason('first') 629 | await store.commit.crossTab( 630 | { type: 'historyLine', value: '|' }, 631 | { id: '0 10:test1 0', reasons: ['test'] } 632 | ) 633 | await delay(1) 634 | expect(store.state.value).toBe('0|') 635 | }) 636 | 637 | it('cleans action added without reason', async () => { 638 | let store = createStore({ historyLine }, { reasonlessHistory: 3 }) 639 | 640 | store.commit.local({ type: 'historyLine', value: 0 }, { reasons: ['test'] }) 641 | expect(store.log.entries()[0][1].reasons).toEqual(['test']) 642 | 643 | function add(index: number) { 644 | return () => { 645 | store.commit({ type: 'historyLine', value: 4 * index - 3 }) 646 | store.commit.local({ type: 'historyLine', value: 4 * index - 2 }) 647 | store.commit.crossTab({ type: 'historyLine', value: 4 * index - 1 }) 648 | store.commit.sync({ type: 'historyLine', value: 4 * index }) 649 | } 650 | } 651 | 652 | let promise = Promise.resolve() 653 | for (let i = 1; i <= 6; i++) { 654 | promise = promise.then(add(i)) 655 | } 656 | 657 | await promise 658 | await delay(1) 659 | 660 | let entries = store.log.entries() 661 | let last = entries[entries.length - 1] 662 | expect(last[1].reasons).toEqual(['syncing', 'timeTravel']) 663 | store.commit({ type: 'historyLine', value: 25 }) 664 | await store.log.removeReason('syncing') 665 | await delay(1) 666 | expect(store.log.actions()).toEqual([ 667 | { type: 'historyLine', value: 0 }, 668 | { type: 'historyLine', value: 23 }, 669 | { type: 'historyLine', value: 24 }, 670 | { type: 'historyLine', value: 25 } 671 | ]) 672 | }) 673 | 674 | it('cleans last 1000 by default', async () => { 675 | let store = createStore({ increment }) 676 | 677 | let promise = Promise.resolve() 678 | for (let i = 0; i < 1050; i++) { 679 | promise = promise.then(() => { 680 | store.commit({ type: 'increment' }) 681 | }) 682 | } 683 | await promise 684 | await delay(1) 685 | expect(store.log.actions()).toHaveLength(1000) 686 | }) 687 | 688 | it('copies reasons to undo action', async () => { 689 | let store = createStore({ increment }) 690 | let nodeId = store.client.nodeId 691 | 692 | await store.commit.crossTab({ type: 'increment' }, { reasons: ['a', 'b'] }) 693 | await store.commit.crossTab( 694 | { type: 'logux/undo', id: `1 ${nodeId} 0` }, 695 | { reasons: [] } 696 | ) 697 | let result = await store.log.byId(`2 ${nodeId} 0`) 698 | if (result[0] === null) throw new Error('Action was not found') 699 | expect(result[0].type).toBe('logux/undo') 700 | expect(result[1].reasons).toEqual(['a', 'b']) 701 | }) 702 | 703 | it('commits local actions', async () => { 704 | let store = createStore({ increment }) 705 | 706 | await store.commit.local({ type: 'increment' }, { reasons: ['test'] }) 707 | let log = store.log.entries() 708 | expect(log[0][0]).toEqual({ type: 'increment' }) 709 | expect(log[0][1].tab).toEqual(store.client.tabId) 710 | expect(log[0][1].reasons).toEqual(['test']) 711 | }) 712 | 713 | it('allows to miss meta for local actions', async () => { 714 | let store = createStore({ increment }) 715 | store.log.on('preadd', (action, meta) => { 716 | meta.reasons.push('preadd') 717 | }) 718 | await store.commit.local({ type: 'increment' }) 719 | expect(store.log.entries()[0][0]).toEqual({ type: 'increment' }) 720 | }) 721 | 722 | it('commits sync actions', async () => { 723 | let store = createStore({ increment }) 724 | 725 | store.commit.sync({ type: 'increment' }, { reasons: ['test'] }) 726 | await delay(1) 727 | let log = store.log.entries() 728 | expect(log[0][0]).toEqual({ type: 'increment' }) 729 | expect(log[0][1].sync).toBe(true) 730 | expect(log[0][1].reasons).toEqual(['test', 'syncing']) 731 | }) 732 | 733 | it('cleans sync action after processing', async () => { 734 | jest.spyOn(console, 'warn').mockImplementation(() => {}) 735 | let pair = new TestPair() 736 | let store = createStore({ increment }, { server: pair.left }) 737 | let resultA, resultB 738 | 739 | store.commit 740 | .sync({ type: 'A' }) 741 | .then(() => { 742 | resultA = 'processed' 743 | }) 744 | .catch(e => { 745 | expect(e.message).toContain('undid') 746 | expect(e.message).toContain('because of error') 747 | resultA = e.action.reason 748 | }) 749 | 750 | store.commit 751 | .sync({ type: 'B' }, { id: '3 10:1:1 0' }) 752 | .then(() => { 753 | resultB = 'processed' 754 | }) 755 | .catch(e => { 756 | expect(e.message).toContain('undid') 757 | expect(e.message).toContain('because of error') 758 | resultB = e.action.reason 759 | }) 760 | 761 | store.log.removeReason('timeTravel') 762 | await store.log.add({ type: 'logux/processed', id: '0 10:1:1 0' }) 763 | expect(resultA).toBeUndefined() 764 | expect(resultB).toBeUndefined() 765 | expect(store.log.actions()).toEqual([{ type: 'A' }, { type: 'B' }]) 766 | await store.log.add({ type: 'logux/processed', id: '1 10:1:1 0' }) 767 | expect(resultA).toBe('processed') 768 | expect(resultB).toBeUndefined() 769 | expect(store.log.actions()).toEqual([{ type: 'B' }]) 770 | store.log.add({ type: 'logux/undo', reason: 'error', id: '3 10:1:1 0' }) 771 | await delay(1) 772 | expect(resultB).toBe('error') 773 | expect(store.log.actions()).toEqual([]) 774 | // eslint-disable-next-line no-console 775 | expect(console.warn).not.toHaveBeenCalled() 776 | }) 777 | 778 | it('applies old actions from store', async () => { 779 | let store1 = createStore({ historyLine }, { reasonlessHistory: 2 }) 780 | let store2 781 | 782 | await Promise.all([ 783 | store1.commit.crossTab( 784 | { type: 'historyLine', value: '1' }, 785 | { id: '0 10:x 1', reasons: ['test'] } 786 | ), 787 | store1.commit.crossTab( 788 | { type: 'historyLine', value: '2' }, 789 | { id: '0 10:x 2', reasons: ['test'] } 790 | ), 791 | store1.commit.crossTab( 792 | { type: 'historyLine', value: '3' }, 793 | { id: '0 10:x 3', reasons: ['test'] } 794 | ), 795 | store1.commit.crossTab( 796 | { type: 'historyLine', value: '4' }, 797 | { id: '0 10:x 4', reasons: ['test'] } 798 | ), 799 | store1.log.add( 800 | { type: 'historyLine', value: '5' }, 801 | { id: '0 10:x 5', reasons: ['test'], tab: 'test2' } 802 | ), 803 | store1.commit.crossTab( 804 | { type: 'logux/undo', id: '0 10:x 2' }, 805 | { id: '0 10:x 6', reasons: ['test'] } 806 | ) 807 | ]) 808 | store2 = createStore({ historyLine }, { store: store1.log.store }) 809 | 810 | store2.commit({ type: 'historyLine', value: 'a' }) 811 | store2.commit({ type: 'historyLine', value: 'b' }) 812 | store2.commit.crossTab( 813 | { type: 'historyLine', value: 'c' }, 814 | { reasons: ['test'] } 815 | ) 816 | store2.commit({ type: 'historyLine', value: 'd' }) 817 | store2.commit({ type: 'historyLine', value: 'e' }) 818 | expect(store2.state.value).toBe('0abde') 819 | 820 | await store2.initialize 821 | expect(store2.state.value).toBe('0134abcde') 822 | }) 823 | 824 | it('applies old actions from store in modules', async () => { 825 | let store1 = createStore( 826 | {}, 827 | { reasonlessHistory: 2 }, 828 | { 829 | user: { 830 | namespaced: false, 831 | state: { value: 0 }, 832 | mutations: { 833 | 'user/historyLine': historyLine 834 | } 835 | } 836 | } 837 | ) 838 | let store2 839 | 840 | await Promise.all([ 841 | store1.commit.crossTab( 842 | { type: 'user/historyLine', value: '1' }, 843 | { id: '0 10:x 1', reasons: ['test'] } 844 | ), 845 | store1.commit.crossTab( 846 | { type: 'user/historyLine', value: '2' }, 847 | { id: '0 10:x 2', reasons: ['test'] } 848 | ), 849 | store1.commit.crossTab( 850 | { type: 'user/historyLine', value: '3' }, 851 | { id: '0 10:x 3', reasons: ['test'] } 852 | ), 853 | store1.commit.crossTab( 854 | { type: 'user/historyLine', value: '4' }, 855 | { id: '0 10:x 4', reasons: ['test'] } 856 | ), 857 | store1.log.add( 858 | { type: 'user/historyLine', value: '5' }, 859 | { id: '0 10:x 5', reasons: ['test'], tab: 'test2' } 860 | ), 861 | store1.commit.crossTab( 862 | { type: 'logux/undo', id: '0 10:x 2' }, 863 | { id: '0 10:x 6', reasons: ['test'] } 864 | ) 865 | ]) 866 | store2 = createStore( 867 | {}, 868 | { store: store1.log.store }, 869 | { 870 | user: { 871 | namespaced: false, 872 | state: () => ({ value: 0 }), 873 | mutations: { 874 | 'user/historyLine': historyLine 875 | } 876 | } 877 | } 878 | ) 879 | 880 | store2.commit({ type: 'user/historyLine', value: 'a' }) 881 | store2.commit({ type: 'user/historyLine', value: 'b' }) 882 | store2.commit.crossTab( 883 | { type: 'user/historyLine', value: 'c' }, 884 | { reasons: ['test'] } 885 | ) 886 | store2.commit({ type: 'user/historyLine', value: 'd' }) 887 | store2.commit({ type: 'user/historyLine', value: 'e' }) 888 | if (typeof store2.state.user === 'undefined') { 889 | throw new Error('user is undefined') 890 | } 891 | expect(store2.state.user.value).toBe('0abde') 892 | 893 | await store2.initialize 894 | expect(store2.state.user.value).toBe('0134abcde') 895 | }) 896 | 897 | it('applies old actions from store in namespaced modules', async () => { 898 | let store1 = createStore( 899 | {}, 900 | { reasonlessHistory: 2 }, 901 | { 902 | user: { 903 | namespaced: true, 904 | state: { value: 0 }, 905 | mutations: { historyLine } 906 | } 907 | } 908 | ) 909 | let store2 910 | 911 | await Promise.all([ 912 | store1.commit.crossTab( 913 | { type: 'user/historyLine', value: '1' }, 914 | { id: '0 10:x 1', reasons: ['test'] } 915 | ), 916 | store1.commit.crossTab( 917 | { type: 'user/historyLine', value: '2' }, 918 | { id: '0 10:x 2', reasons: ['test'] } 919 | ), 920 | store1.commit.crossTab( 921 | { type: 'user/historyLine', value: '3' }, 922 | { id: '0 10:x 3', reasons: ['test'] } 923 | ), 924 | store1.commit.crossTab( 925 | { type: 'user/historyLine', value: '4' }, 926 | { id: '0 10:x 4', reasons: ['test'] } 927 | ), 928 | store1.log.add( 929 | { type: 'user/historyLine', value: '5' }, 930 | { id: '0 10:x 5', reasons: ['test'], tab: 'test2' } 931 | ), 932 | store1.commit.crossTab( 933 | { type: 'logux/undo', id: '0 10:x 2' }, 934 | { id: '0 10:x 6', reasons: ['test'] } 935 | ) 936 | ]) 937 | store2 = createStore( 938 | {}, 939 | { store: store1.log.store }, 940 | { 941 | user: { 942 | namespaced: true, 943 | state: { value: 0 }, 944 | mutations: { historyLine } 945 | } 946 | } 947 | ) 948 | 949 | store2.commit({ type: 'user/historyLine', value: 'a' }) 950 | store2.commit({ type: 'user/historyLine', value: 'b' }) 951 | store2.commit.crossTab( 952 | { type: 'user/historyLine', value: 'c' }, 953 | { reasons: ['test'] } 954 | ) 955 | store2.commit({ type: 'user/historyLine', value: 'd' }) 956 | store2.commit({ type: 'user/historyLine', value: 'e' }) 957 | if (typeof store2.state.user === 'undefined') { 958 | throw new Error('user is undefined') 959 | } 960 | expect(store2.state.user.value).toBe('0abde') 961 | 962 | await store2.initialize 963 | expect(store2.state.user.value).toBe('0134abcde') 964 | }) 965 | 966 | it('applies old actions from store in nested modules', async () => { 967 | let client1 = createClient() 968 | let _createStore1 = createStoreCreator(client1) 969 | let store1 = _createStore1({ 970 | state: { value: 0 }, 971 | mutations: { historyLine }, 972 | modules: { 973 | a: { 974 | namespaced: true, 975 | state: { value: 0 }, 976 | mutations: { historyLine }, 977 | modules: { 978 | b: { 979 | state: { value: 0 }, 980 | mutations: { historyLine }, 981 | modules: { 982 | c: { 983 | namespaced: true, 984 | state: { value: 0 }, 985 | mutations: { historyLine }, 986 | modules: { 987 | d: { 988 | namespaced: true, 989 | state: { value: 0 }, 990 | mutations: { historyLine } 991 | } 992 | } 993 | } 994 | } 995 | } 996 | } 997 | } 998 | } 999 | }) 1000 | 1001 | await Promise.all([ 1002 | store1.commit.crossTab('historyLine', '1', { 1003 | id: '0 10:x 1', 1004 | reasons: ['test'] 1005 | }), 1006 | store1.commit.crossTab('a/historyLine', '2', { 1007 | id: '0 10:x 2', 1008 | reasons: ['test'] 1009 | }), 1010 | store1.commit.crossTab('a/c/d/historyLine', '3', { 1011 | id: '0 10:x 3', 1012 | reasons: ['test'] 1013 | }) 1014 | ]) 1015 | 1016 | let client2 = createClient({ store: store1.log.store }) 1017 | let _createStore2 = createStoreCreator(client2) 1018 | 1019 | interface Store2State { 1020 | value: number | string 1021 | a?: { 1022 | value: number | string 1023 | b: { 1024 | value: number | string 1025 | c: { 1026 | value: number | string 1027 | d: { 1028 | value: number | string 1029 | } 1030 | } 1031 | } 1032 | } 1033 | } 1034 | 1035 | let store2 = _createStore2({ 1036 | state: () => ({ value: 0 }), 1037 | mutations: { historyLine }, 1038 | modules: { 1039 | a: { 1040 | namespaced: true, 1041 | state: { value: 0 }, 1042 | mutations: { historyLine }, 1043 | modules: { 1044 | b: { 1045 | state: { value: 0 }, 1046 | mutations: { historyLine }, 1047 | modules: { 1048 | c: { 1049 | namespaced: true, 1050 | state: () => ({ value: 0 }), 1051 | mutations: { historyLine }, 1052 | modules: { 1053 | d: { 1054 | namespaced: true, 1055 | state: { value: 0 }, 1056 | mutations: { historyLine } 1057 | } 1058 | } 1059 | } 1060 | } 1061 | } 1062 | } 1063 | } 1064 | } 1065 | }) 1066 | 1067 | store2.commit('historyLine', 'a') 1068 | if (typeof store2.state.a === 'undefined') throw new Error('a is undefined') 1069 | expect(store2.state.value).toBe('0a') 1070 | expect(store2.state.a.value).toBe(0) 1071 | expect(store2.state.a.b.value).toBe(0) 1072 | 1073 | store2.commit('a/historyLine', 'b') 1074 | expect(store2.state.value).toBe('0a') 1075 | expect(store2.state.a.value).toBe('0b') 1076 | expect(store2.state.a.b.value).toBe('0b') 1077 | expect(store2.state.a.b.c.value).toBe(0) 1078 | 1079 | store2.commit('a/c/d/historyLine', 'd') 1080 | expect(store2.state.value).toBe('0a') 1081 | expect(store2.state.a.value).toBe('0b') 1082 | expect(store2.state.a.b.value).toBe('0b') 1083 | expect(store2.state.a.b.c.value).toBe(0) 1084 | expect(store2.state.a.b.c.d.value).toBe('0d') 1085 | 1086 | await store2.initialize 1087 | expect(store2.state.value).toBe('01a') 1088 | expect(store2.state.a.value).toBe('02b') 1089 | expect(store2.state.a.b.value).toBe('02b') 1090 | expect(store2.state.a.b.c.value).toBe(0) 1091 | expect(store2.state.a.b.c.d.value).toBe('03d') 1092 | }) 1093 | 1094 | it('waits for replaying', async () => { 1095 | let store = createStore({ historyLine }) 1096 | let run: undefined | (() => void) 1097 | let waiting = new Promise(resolve => { 1098 | run = resolve 1099 | }) 1100 | 1101 | let first = true 1102 | let originEach = store.log.each 1103 | store.log.each = async function (...args: any) { 1104 | let result = originEach.apply(this, args) 1105 | if (first) { 1106 | first = false 1107 | await waiting 1108 | } 1109 | return result 1110 | } 1111 | 1112 | await store.commit.crossTab( 1113 | { type: 'historyLine', value: 'b' }, 1114 | { reasons: ['t'] } 1115 | ) 1116 | await store.commit.crossTab( 1117 | { type: 'historyLine', value: 'a' }, 1118 | { id: '0 test 0', reasons: ['t'] } 1119 | ) 1120 | await Promise.all([ 1121 | store.commit.crossTab( 1122 | { type: 'historyLine', value: 'c' }, 1123 | { reasons: ['o'] } 1124 | ), 1125 | store.commit.crossTab( 1126 | { type: 'historyLine', value: 'd' }, 1127 | { reasons: ['t'] } 1128 | ) 1129 | ]) 1130 | delay(1) 1131 | expect(store.state.value).toBe('0b') 1132 | store.log.removeReason('o') 1133 | if (typeof run === 'undefined') throw new Error('run was not set') 1134 | run() 1135 | await delay(10) 1136 | expect(store.state.value).toBe('0abd') 1137 | }) 1138 | 1139 | it('emits change event', async () => { 1140 | let store = createStore({ historyLine }) 1141 | 1142 | store.log.on('preadd', (action, meta) => { 1143 | meta.reasons.push('test') 1144 | }) 1145 | 1146 | let calls: [State, State, Action][] = [] 1147 | store.on('change', (state, prevState, action, meta) => { 1148 | expect(typeof meta.id).toBe('string') 1149 | calls.push([state, prevState, action]) 1150 | }) 1151 | 1152 | store.commit({ type: 'historyLine', value: 'a' }) 1153 | store.commit.local({ type: 'historyLine', value: 'c' }) 1154 | store.commit.local( 1155 | { type: 'historyLine', value: 'b' }, 1156 | { id: '1 10:test1 1' } 1157 | ) 1158 | await delay(10) 1159 | expect(calls).toEqual([ 1160 | [{ value: '0a' }, { value: 0 }, { type: 'historyLine', value: 'a' }], 1161 | [{ value: '0ac' }, { value: '0a' }, { type: 'historyLine', value: 'c' }], 1162 | [{ value: '0abc' }, { value: '0ac' }, { type: 'historyLine', value: 'b' }] 1163 | ]) 1164 | }) 1165 | 1166 | it('warns about undoes cleaned action', async () => { 1167 | let store = createStore({ increment }) 1168 | 1169 | await store.commit.crossTab({ type: 'logux/undo', id: '1 t 0' }) 1170 | expect(store.log.actions()).toHaveLength(0) 1171 | }) 1172 | 1173 | it('does not put reason on request', async () => { 1174 | let store = createStore({}) 1175 | 1176 | await store.commit.crossTab({ type: 'A' }, { noAutoReason: true }) 1177 | await store.commit.crossTab({ type: 'B' }) 1178 | expect(store.log.actions()).toEqual([{ type: 'B' }]) 1179 | 1180 | await store.commit.crossTab({ type: 'a' }, { reasons: ['a'] }) 1181 | await store.commit.crossTab({ type: 'b' }, { keepLast: 'b' }) 1182 | expect(store.log.actions()).toEqual([ 1183 | { type: 'B' }, 1184 | { type: 'a' }, 1185 | { type: 'b' } 1186 | ]) 1187 | expect(store.log.entries()[1][1].noAutoReason).toBe(true) 1188 | expect(store.log.entries()[2][1].noAutoReason).toBe(true) 1189 | }) 1190 | -------------------------------------------------------------------------------- /store/types.ts: -------------------------------------------------------------------------------- 1 | import { CrossTabClient } from '@logux/client' 2 | 3 | import { createStoreCreator } from '../index.js' 4 | 5 | interface RootState { 6 | value: number 7 | } 8 | 9 | let client = new CrossTabClient({ 10 | server: 'wss://localhost:1337', 11 | subprotocol: '1.0.0', 12 | userId: '10' 13 | }) 14 | 15 | let createStore = createStoreCreator(client) 16 | 17 | let store = createStore({ 18 | state: { 19 | value: 0 20 | }, 21 | mutations: { 22 | increment(state) { 23 | state.value = state.value + 1 24 | } 25 | } 26 | }) 27 | 28 | store.commit('increment') 29 | store.commit({ type: 'increment' }) 30 | store.commit({ type: 'increment' }, { silent: true }) 31 | store.commit.local('increment', null, { reasons: ['reason'] }) 32 | store.commit.crossTab({ type: 'increment' }, { reasons: ['reason'] }) 33 | store.commit.sync({ type: 'increment' }).then(meta => { 34 | console.log(meta.id) 35 | }) 36 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "target": "es2018", 5 | "strict": true, 6 | "noEmit": true, 7 | "allowJs": true, 8 | "skipLibCheck": true, 9 | "esModuleInterop": true, 10 | "moduleResolution": "node" 11 | }, 12 | "exclude": [ 13 | "**/errors.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /utils/index.js: -------------------------------------------------------------------------------- 1 | export function find (list, f) { 2 | return list.filter(f)[0] 3 | } 4 | 5 | export function deepCopy (obj, cache = []) { 6 | if (obj === null || typeof obj !== 'object') { 7 | return obj 8 | } 9 | 10 | let hit = find(cache, c => c.original === obj) 11 | if (hit) { 12 | return hit.copy 13 | } 14 | 15 | let copy = Array.isArray(obj) ? [] : {} 16 | cache.push({ 17 | original: obj, 18 | copy 19 | }) 20 | 21 | Object.keys(obj).forEach(key => { 22 | copy[key] = deepCopy(obj[key], cache) 23 | }) 24 | 25 | return copy 26 | } 27 | 28 | export function forEachValue (obj, fn) { 29 | Object.keys(obj).forEach(key => fn(obj[key], key)) 30 | } 31 | 32 | export function isPromise (val) { 33 | return val && typeof val.then === 'function' 34 | } 35 | -------------------------------------------------------------------------------- /utils/index.test.js: -------------------------------------------------------------------------------- 1 | import { find, deepCopy, isPromise } from './index.js' 2 | 3 | it('find', () => { 4 | let list = [33, 22, 112, 222, 43] 5 | expect( 6 | find(list, a => { 7 | return a % 2 === 0 8 | }) 9 | ).toBe(22) 10 | }) 11 | 12 | it('deepCopy: nornal structure', () => { 13 | let original = { 14 | a: 1, 15 | b: 'string', 16 | c: true, 17 | d: null, 18 | e: undefined 19 | } 20 | let copy = deepCopy(original) 21 | 22 | expect(copy).toEqual(original) 23 | }) 24 | 25 | it('deepCopy: nested structure', () => { 26 | let original = { 27 | a: { 28 | b: 1, 29 | c: [ 30 | 2, 31 | 3, 32 | { 33 | d: 4 34 | } 35 | ] 36 | } 37 | } 38 | let copy = deepCopy(original) 39 | 40 | expect(copy).toEqual(original) 41 | }) 42 | 43 | it('deepCopy: circular structure', () => { 44 | let original = { 45 | a: 1 46 | } 47 | original.circular = original 48 | 49 | let copy = deepCopy(original) 50 | 51 | expect(copy).toEqual(original) 52 | }) 53 | 54 | it('isPromise', () => { 55 | let promise = new Promise( 56 | () => {}, 57 | () => {} 58 | ) 59 | let func = () => {} 60 | expect(isPromise(1)).toBe(false) 61 | expect(isPromise(promise)).toBe(true) 62 | expect(isPromise(func)).toBe(false) 63 | }) 64 | --------------------------------------------------------------------------------