├── .prettierignore ├── .gitignore ├── .travis.yml ├── babel.config.js ├── examples ├── nuxt │ ├── .prettierrc │ ├── static │ │ ├── favicon.ico │ │ └── README.md │ ├── plugins │ │ ├── vuex-shared-mutations.js │ │ └── README.md │ ├── .editorconfig │ ├── store │ │ ├── index.js │ │ └── README.md │ ├── layouts │ │ ├── README.md │ │ └── default.vue │ ├── pages │ │ ├── README.md │ │ └── index.vue │ ├── .eslintrc.js │ ├── README.md │ ├── package.json │ ├── nuxt.config.js │ └── .gitignore └── simple │ ├── babel.config.js │ ├── public │ ├── favicon.ico │ └── index.html │ ├── .editorconfig │ ├── src │ ├── main.js │ ├── store.js │ └── App.vue │ ├── .gitignore │ ├── README.md │ └── package.json ├── tests ├── fixtures │ ├── publishOnWindow.js │ └── testTab.html └── e2e.test.js ├── src ├── strategies │ ├── defaultStrategy.js │ ├── broadcastChannel.js │ ├── broadcastChannel.test.js │ ├── localStorage.js │ └── localStorage.test.js ├── vuexSharedMutations.js └── vuexSharedMutations.test.js ├── webpack.config.js ├── wallaby.js ├── LICENSE ├── CHANGELOG.md ├── package.json ├── karma.conf.js └── README.md /.prettierignore: -------------------------------------------------------------------------------- 1 | tests/fixtures -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - 10 5 | 6 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ["@babel/preset-env"], 3 | }; 4 | -------------------------------------------------------------------------------- /examples/nuxt/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /examples/simple/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/app', 4 | ], 5 | }; 6 | -------------------------------------------------------------------------------- /examples/nuxt/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xanf/vuex-shared-mutations/HEAD/examples/nuxt/static/favicon.ico -------------------------------------------------------------------------------- /examples/simple/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xanf/vuex-shared-mutations/HEAD/examples/simple/public/favicon.ico -------------------------------------------------------------------------------- /examples/simple/.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue}] 2 | indent_style = space 3 | indent_size = 2 4 | end_of_line = lf 5 | trim_trailing_whitespace = true 6 | insert_final_newline = true 7 | max_line_length = 100 8 | -------------------------------------------------------------------------------- /examples/simple/src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import App from './App.vue'; 3 | import store from './store'; 4 | 5 | Vue.config.productionTip = false; 6 | 7 | new Vue({ 8 | store, 9 | render: h => h(App), 10 | }).$mount('#app'); 11 | -------------------------------------------------------------------------------- /examples/nuxt/plugins/vuex-shared-mutations.js: -------------------------------------------------------------------------------- 1 | import shareMutations from 'vuex-shared-mutations' 2 | 3 | export default ({ store }) => { 4 | window.onNuxtReady(nuxt => { 5 | shareMutations({ 6 | predicate: ['increment', 'decrement'] 7 | })(store) 8 | }) 9 | } 10 | -------------------------------------------------------------------------------- /examples/nuxt/.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /examples/nuxt/store/index.js: -------------------------------------------------------------------------------- 1 | export const state = () => ({ 2 | count: 0 3 | }) 4 | 5 | export const mutations = { 6 | increment(state) { 7 | state.count++ 8 | }, 9 | decrement(state) { 10 | state.count-- 11 | }, 12 | increment2(state) { 13 | state.count += 2 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /examples/nuxt/layouts/README.md: -------------------------------------------------------------------------------- 1 | # LAYOUTS 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains your Application Layouts. 6 | 7 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/views#layouts). 8 | -------------------------------------------------------------------------------- /examples/nuxt/pages/README.md: -------------------------------------------------------------------------------- 1 | # PAGES 2 | 3 | This directory contains your Application Views and Routes. 4 | The framework reads all the `*.vue` files inside this directory and creates the router of your application. 5 | 6 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/routing). 7 | -------------------------------------------------------------------------------- /examples/simple/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw* 22 | -------------------------------------------------------------------------------- /tests/fixtures/publishOnWindow.js: -------------------------------------------------------------------------------- 1 | import vuexSharedMutations, { 2 | BroadcastChannelStrategy, 3 | LocalStorageStratery, 4 | } from "../../src/vuexSharedMutations"; 5 | 6 | window.vuexSharedMutations = vuexSharedMutations; 7 | window.BroadcastChannelStrategy = BroadcastChannelStrategy; 8 | window.LocalStorageStrategy = LocalStorageStratery; 9 | 10 | -------------------------------------------------------------------------------- /examples/nuxt/plugins/README.md: -------------------------------------------------------------------------------- 1 | # PLUGINS 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains Javascript plugins that you want to run before mounting the root Vue.js application. 6 | 7 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/plugins). 8 | -------------------------------------------------------------------------------- /examples/nuxt/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | browser: true, 5 | node: true 6 | }, 7 | parserOptions: { 8 | parser: 'babel-eslint' 9 | }, 10 | extends: ['@nuxtjs', 'plugin:prettier/recommended'], 11 | plugins: ['prettier'], 12 | // add your custom rules here 13 | rules: { 14 | 'vue/singleline-html-element-content-newline': 0, 15 | 'vue/multiline-html-element-content-newline': 0 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /examples/nuxt/store/README.md: -------------------------------------------------------------------------------- 1 | # STORE 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains your Vuex Store files. 6 | Vuex Store option is implemented in the Nuxt.js framework. 7 | 8 | Creating a file in this directory automatically activates the option in the framework. 9 | 10 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/vuex-store). 11 | -------------------------------------------------------------------------------- /examples/nuxt/static/README.md: -------------------------------------------------------------------------------- 1 | # STATIC 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains your static files. 6 | Each file inside this directory is mapped to `/`. 7 | Thus you'd want to delete this README.md before deploying to production. 8 | 9 | Example: `/static/robots.txt` is mapped as `/robots.txt`. 10 | 11 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/assets#static). 12 | -------------------------------------------------------------------------------- /examples/nuxt/README.md: -------------------------------------------------------------------------------- 1 | # vuex-shared-mutations-nuxt-demo 2 | 3 | > Vuex-shared-mutations <3 Nuxt.js 4 | 5 | ## Build Setup 6 | 7 | ``` bash 8 | # install dependencies 9 | $ yarn install 10 | 11 | # serve with hot reload at localhost:3000 12 | $ yarn run dev 13 | 14 | # build for production and launch server 15 | $ yarn run build 16 | $ yarn start 17 | 18 | # generate static project 19 | $ yarn run generate 20 | ``` 21 | 22 | For detailed explanation on how things work, checkout [Nuxt.js docs](https://nuxtjs.org). 23 | -------------------------------------------------------------------------------- /examples/simple/README.md: -------------------------------------------------------------------------------- 1 | # vuex-shared-mutations-simple-demo 2 | 3 | ## Project setup 4 | ``` 5 | npm install 6 | ``` 7 | 8 | ### Compiles and hot-reloads for development 9 | ``` 10 | npm run serve 11 | ``` 12 | 13 | ### Compiles and minifies for production 14 | ``` 15 | npm run build 16 | ``` 17 | 18 | ### Run your tests 19 | ``` 20 | npm run test 21 | ``` 22 | 23 | ### Lints and fixes files 24 | ``` 25 | npm run lint 26 | ``` 27 | 28 | ### Customize configuration 29 | See [Configuration Reference](https://cli.vuejs.org/config/). 30 | -------------------------------------------------------------------------------- /examples/simple/src/store.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuex from 'vuex'; 3 | import sharedMutations from 'vuex-shared-mutations'; 4 | 5 | Vue.use(Vuex); 6 | 7 | export default new Vuex.Store({ 8 | state: { 9 | count: 0, 10 | }, 11 | mutations: { 12 | increment(state) { 13 | state.count += 1; 14 | }, 15 | decrement(state) { 16 | state.count -= 1; 17 | }, 18 | increment2(state) { 19 | state.count += 2; 20 | }, 21 | }, 22 | plugins: [sharedMutations({ predicate: ['increment', 'decrement'] })], 23 | }); 24 | -------------------------------------------------------------------------------- /src/strategies/defaultStrategy.js: -------------------------------------------------------------------------------- 1 | import BroadcastChannelStrategy from "./broadcastChannel"; 2 | import LocalStorageStrategy from "./localStorage"; 3 | 4 | export default function createDefaultStrategy() { 5 | /* istanbul ignore next: browser-dependent code */ 6 | if (LocalStorageStrategy.available()) { 7 | return new LocalStorageStrategy(); 8 | } 9 | 10 | /* istanbul ignore next: browser-dependent code */ 11 | if (BroadcastChannelStrategy.available()) { 12 | return new BroadcastChannelStrategy(); 13 | } 14 | 15 | /* istanbul ignore next: browser-dependent code */ 16 | throw new Error("No strategies available"); 17 | } 18 | -------------------------------------------------------------------------------- /examples/simple/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | vuex-shared-mutations-simple-demo 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | const mode = 4 | process.env.NODE_ENV === "production" ? "production" : "development"; 5 | 6 | module.exports = { 7 | mode, 8 | entry: "./src/vuexSharedMutations.js", 9 | devtool: "source-map", 10 | output: { 11 | path: path.resolve(__dirname, "dist"), 12 | filename: "vuex-shared-mutations.js", 13 | library: "vuexSharedMutations", 14 | libraryTarget: "umd", 15 | }, 16 | module: { 17 | rules: [ 18 | { 19 | test: /\.js$/, 20 | include: [path.resolve(__dirname, "src")], 21 | use: { 22 | loader: "babel-loader", 23 | }, 24 | }, 25 | { 26 | test: /\.js$/, 27 | include: [ 28 | path.resolve(__dirname, "tests"), 29 | path.resolve(__dirname, "node_modules", "chai-as-promised"), 30 | ], 31 | use: { 32 | loader: "babel-loader", 33 | }, 34 | }, 35 | ], 36 | }, 37 | }; 38 | -------------------------------------------------------------------------------- /src/strategies/broadcastChannel.js: -------------------------------------------------------------------------------- 1 | const DEFAULT_CHANNEL = "vuex-shared-mutations"; 2 | 3 | const globalObj = 4 | typeof window !== "undefined" 5 | ? window 6 | : /* istanbul ignore next: node env */ global; 7 | 8 | export default class BroadcastChannelStrategy { 9 | static available(BroadcastChannelImpl = globalObj.BroadcastChannel) { 10 | return !(typeof BroadcastChannelImpl !== "function"); 11 | } 12 | 13 | constructor(options = {}) { 14 | const BroadcastChannelImpl = 15 | options.BroadcastChannel || globalObj.BroadcastChannel; 16 | const key = options.key || DEFAULT_CHANNEL; 17 | 18 | if (!this.constructor.available(BroadcastChannelImpl)) { 19 | throw new Error("Broadcast strategy not available"); 20 | } 21 | 22 | this.channel = new BroadcastChannelImpl(key); 23 | } 24 | 25 | addEventListener(fn) { 26 | this.channel.addEventListener("message", e => { 27 | fn(e.data); 28 | }); 29 | } 30 | 31 | share(message) { 32 | return this.channel.postMessage(message); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /wallaby.js: -------------------------------------------------------------------------------- 1 | /*eslint-disable */ 2 | const wallabyWebpack = require("wallaby-webpack"); 3 | const webpackPostprocessor = wallabyWebpack(); 4 | module.exports = function(wallaby) { 5 | return { 6 | files: [ 7 | { pattern: "src/**/*.js", load: false }, 8 | { pattern: "!src/**/*.test.js" }, 9 | { pattern: "tests/fixtures/publishOnWindow.js" } 10 | ], 11 | tests: [ 12 | { pattern: "src/**/*.test.js", load: false }, 13 | { pattern: "tests/**/*.js", load: false }, 14 | { pattern: "!tests/fixtures/**" } 15 | ], 16 | testFramework: "mocha", 17 | compilers: { 18 | "**/*.js": wallaby.compilers.babel() 19 | }, 20 | env: { 21 | type: "browser", 22 | kind: "chrome" 23 | }, 24 | 25 | postprocessor: webpackPostprocessor, 26 | 27 | bootstrap() { 28 | window.__moduleBundler.loadTests(); 29 | }, 30 | 31 | hints: { 32 | ignoreCoverage: /istanbul ignore next/ // or /istanbul ignore next/, or any RegExp 33 | }, 34 | 35 | middleware: (app, express) => { 36 | app.use("/base", express.static(__dirname)); 37 | } 38 | }; 39 | }; 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-2019 Illya Klymov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, 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, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /examples/nuxt/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 56 | -------------------------------------------------------------------------------- /examples/nuxt/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vuex-shared-mutations-nuxt-demo", 3 | "version": "1.0.0", 4 | "description": "Vuex-shared-mutations <3 Nuxt.js", 5 | "author": "Illya Klymov", 6 | "private": true, 7 | "scripts": { 8 | "dev": "nuxt", 9 | "build": "nuxt build", 10 | "start": "nuxt start", 11 | "generate": "nuxt generate", 12 | "lint": "eslint --ext .js,.vue --ignore-path .gitignore .", 13 | "precommit": "npm run lint" 14 | }, 15 | "dependencies": { 16 | "cross-env": "^5.2.0", 17 | "nuxt": "^2.4.0", 18 | "vuex-shared-mutations": "1.0.2" 19 | }, 20 | "devDependencies": { 21 | "nodemon": "^1.18.9", 22 | "@nuxtjs/eslint-config": "^0.0.1", 23 | "babel-eslint": "^8.2.1", 24 | "eslint": "^5.0.1", 25 | "eslint-config-standard": ">=12.0.0", 26 | "eslint-plugin-import": ">=2.14.0", 27 | "eslint-plugin-jest": ">=21.24.1", 28 | "eslint-plugin-node": ">=7.0.1", 29 | "eslint-plugin-promise": ">=4.0.1", 30 | "eslint-plugin-standard": ">=4.0.0", 31 | "eslint-loader": "^2.0.0", 32 | "eslint-plugin-vue": "^5.0.0", 33 | "eslint-config-prettier": "^3.1.0", 34 | "eslint-plugin-prettier": "2.6.2", 35 | "prettier": "1.14.3" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /examples/nuxt/pages/index.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 42 | -------------------------------------------------------------------------------- /examples/simple/src/App.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 45 | -------------------------------------------------------------------------------- /examples/nuxt/nuxt.config.js: -------------------------------------------------------------------------------- 1 | const pkg = require('./package') 2 | 3 | module.exports = { 4 | mode: 'universal', 5 | 6 | /* 7 | ** Headers of the page 8 | */ 9 | head: { 10 | title: pkg.name, 11 | meta: [ 12 | { charset: 'utf-8' }, 13 | { name: 'viewport', content: 'width=device-width, initial-scale=1' }, 14 | { hid: 'description', name: 'description', content: pkg.description } 15 | ], 16 | link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }] 17 | }, 18 | 19 | /* 20 | ** Customize the progress-bar color 21 | */ 22 | loading: { color: '#fff' }, 23 | 24 | /* 25 | ** Global CSS 26 | */ 27 | css: [], 28 | 29 | /* 30 | ** Plugins to load before mounting the App 31 | */ 32 | plugins: [{ src: '~plugins/vuex-shared-mutations.js', ssr: false }], 33 | 34 | /* 35 | ** Nuxt.js modules 36 | */ 37 | modules: [], 38 | 39 | /* 40 | ** Build configuration 41 | */ 42 | build: { 43 | /* 44 | ** You can extend webpack config here 45 | */ 46 | extend(config, ctx) { 47 | // Run ESLint on save 48 | if (ctx.isDev && ctx.isClient) { 49 | config.module.rules.push({ 50 | enforce: 'pre', 51 | test: /\.(js|vue)$/, 52 | loader: 'eslint-loader', 53 | exclude: /(node_modules)/ 54 | }) 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /examples/simple/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vuex-shared-mutations-simple-demo", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "lint": "vue-cli-service lint" 9 | }, 10 | "prettier": { 11 | "singleQuote": true, 12 | "trailingComma": "es5" 13 | }, 14 | "dependencies": { 15 | "vue": "^2.5.22", 16 | "vuex": "^3.0.1", 17 | "vuex-shared-mutations": "^1.0.2" 18 | }, 19 | "devDependencies": { 20 | "@vue/cli-plugin-babel": "^3.4.0", 21 | "@vue/cli-plugin-eslint": "^3.4.0", 22 | "@vue/cli-service": "^3.4.0", 23 | "@vue/eslint-config-airbnb": "^4.0.0", 24 | "babel-eslint": "^10.0.1", 25 | "eslint": "^5.8.0", 26 | "eslint-plugin-vue": "^5.0.0", 27 | "vue-template-compiler": "^2.5.21" 28 | }, 29 | "eslintConfig": { 30 | "root": true, 31 | "env": { 32 | "node": true 33 | }, 34 | "extends": [ 35 | "plugin:vue/essential", 36 | "@vue/airbnb" 37 | ], 38 | "rules": { 39 | "no-param-reassign": 0 40 | }, 41 | "parserOptions": { 42 | "parser": "babel-eslint" 43 | } 44 | }, 45 | "postcss": { 46 | "plugins": { 47 | "autoprefixer": {} 48 | } 49 | }, 50 | "browserslist": [ 51 | "> 1%", 52 | "last 2 versions", 53 | "not ie <= 8" 54 | ] 55 | } 56 | -------------------------------------------------------------------------------- /examples/nuxt/.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Node template 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Bower dependency directory (https://bower.io/) 29 | bower_components 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (https://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # TypeScript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # Yarn Integrity file 57 | .yarn-integrity 58 | 59 | # dotenv environment variables file 60 | .env 61 | 62 | # parcel-bundler cache (https://parceljs.org/) 63 | .cache 64 | 65 | # next.js build output 66 | .next 67 | 68 | # nuxt.js build output 69 | .nuxt 70 | 71 | # Nuxt generate 72 | dist 73 | 74 | # vuepress build output 75 | .vuepress/dist 76 | 77 | # Serverless directories 78 | .serverless 79 | 80 | # IDE 81 | .idea 82 | 83 | # Service worker 84 | sw.* 85 | -------------------------------------------------------------------------------- /src/vuexSharedMutations.js: -------------------------------------------------------------------------------- 1 | import createDefaultStrategy from "./strategies/defaultStrategy"; 2 | 3 | export { 4 | default as BroadcastChannelStrategy 5 | } from "./strategies/broadcastChannel"; 6 | export { default as LocalStorageStratery } from "./strategies/localStorage"; 7 | 8 | export default ({ predicate, strategy, ...rest } = {}) => { 9 | /* istanbul ignore next: deprecation warning */ 10 | if ("storageKey" in rest || "sharingKey" in rest) { 11 | window.console.warn( 12 | "Configuration directly on plugin was removed, configure specific strategies if needed" 13 | ); 14 | } 15 | 16 | if (!Array.isArray(predicate) && typeof predicate !== "function") { 17 | throw new Error( 18 | "Either array of accepted mutations or predicate function must be supplied" 19 | ); 20 | } 21 | 22 | const predicateFn = 23 | typeof predicate === "function" 24 | ? predicate 25 | : ({ type }) => predicate.indexOf(type) !== -1; 26 | 27 | let sharingInProgress = false; 28 | const selectedStrategy = strategy || createDefaultStrategy(); 29 | return store => { 30 | store.subscribe((mutation, state) => { 31 | if (sharingInProgress) { 32 | return Promise.resolve(false); 33 | } 34 | 35 | return Promise.resolve(predicateFn(mutation, state)).then(shouldShare => { 36 | if (!shouldShare) { 37 | return; 38 | } 39 | selectedStrategy.share(mutation); 40 | }); 41 | }); 42 | 43 | selectedStrategy.addEventListener(mutation => { 44 | try { 45 | sharingInProgress = true; 46 | store.commit(mutation.type, mutation.payload); 47 | } finally { 48 | sharingInProgress = false; 49 | } 50 | return "done"; 51 | }); 52 | }; 53 | }; 54 | -------------------------------------------------------------------------------- /tests/fixtures/testTab.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Test tab 4 | 5 | 6 | 7 | 8 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /src/strategies/broadcastChannel.test.js: -------------------------------------------------------------------------------- 1 | import chai from "chai"; 2 | import td from "testdouble"; 3 | import tdChai from "testdouble-chai"; 4 | import BroadcastChannelStrategy from "./broadcastChannel"; 5 | 6 | chai.use(tdChai(td)); 7 | const { expect } = chai; 8 | 9 | describe("BroadcastChannelStrategy", () => { 10 | it("should report as unavailable if BroadcastChannel is not available", () => { 11 | expect( 12 | () => 13 | new BroadcastChannelStrategy({ 14 | BroadcastChannel: { dummy: true } 15 | }) 16 | ).to.throw(Error); 17 | }); 18 | 19 | it("should respect key options", () => { 20 | const FakeChannel = td.constructor(["postMessage"]); 21 | const KEY = "CUSTOM KEY"; 22 | const strategy = new BroadcastChannelStrategy({ 23 | key: KEY, 24 | BroadcastChannel: FakeChannel 25 | }); 26 | const message = { demo: "message" }; 27 | strategy.share(message); 28 | expect(FakeChannel).have.been.calledWith(KEY); 29 | }); 30 | 31 | it("should call postMessage on broadcastChannel when share is called", () => { 32 | const dummyChannel = { 33 | postMessage: td.func() 34 | }; 35 | const strategy = new BroadcastChannelStrategy({ 36 | BroadcastChannel: () => dummyChannel 37 | }); 38 | const message = { demo: "message" }; 39 | strategy.share(message); 40 | expect(dummyChannel.postMessage).to.have.been.calledWith(message); 41 | }); 42 | 43 | it("should subscribe to message event", () => { 44 | const dummyChannel = { 45 | addEventListener: td.func() 46 | }; 47 | const strategy = new BroadcastChannelStrategy({ 48 | BroadcastChannel: () => dummyChannel 49 | }); 50 | const dummyFn = () => {}; 51 | strategy.addEventListener(dummyFn); 52 | expect(dummyChannel.addEventListener).to.have.been.calledWith( 53 | "message", 54 | td.matchers.anything() 55 | ); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/) 6 | and this project adheres to [Semantic Versioning](http://semver.org/). 7 | 8 | ## [1.0.0 - 2019-02-03] 9 | 10 | Major release, entire plugin rewrite 11 | 12 | ### Added 13 | 14 | - Support for multiple strategies (currently `BroadcastChannel` and `LocalStorage` are supported) 15 | 16 | ### Fixed 17 | 18 | - Internet Explorer support when sharing big payloads 19 | 20 | ### Other 21 | 22 | - Migrated to karma + mocha from ava 23 | - E2E tests are run on real browsers to ensure browsers specifics is taken into account 24 | 25 | ## [0.0.7 - 2019-01-07] 26 | 27 | - Proper build of 0.0.6, no major changes 28 | 29 | ## [0.0.6 - 2019-01-07] 30 | 31 | ### Fixed 32 | 33 | - Issue introduced in 0.0.5 when plugin failed to work in Chrome due to concurrency issues (#13) 34 | 35 | ## [0.0.5 - 2018-11-11] 36 | 37 | Thanks to [@jameswragg](https://github.com/jameswragg) and [@justin-schroeder](https://github.com/justin-schroeder) for this version 38 | 39 | ### Fixed 40 | 41 | - IE11 is not triggering storage events if payload is bigger than 16kb 42 | 43 | ## [0.0.4 - 2018-06-22] 44 | 45 | Credits to [@qkdreyer ](https://github.com/qkdreyer) for this version 46 | 47 | ### Fixed 48 | 49 | - Bump vuex peer dependency 50 | 51 | ## [0.0.3 - 2017-02-20] 52 | 53 | Credits to [@LeonardPauli](https://github.com/LeonardPauli) for this version 54 | 55 | ### Fixed 56 | 57 | - Repeating the same mutation would previously only have shared the first commit 58 | 59 | ### Added 60 | 61 | - Readme section "Contributing" 62 | - Readme section "How it works" 63 | - Readme predicate function usage example 64 | - Passing `state` to predicate function allows for invoking other plugins in the predicate 65 | 66 | ## [0.0.2] - 2017-02-01 67 | 68 | ### Added 69 | 70 | - Print error and fail early if localStorage.setItem throws an error 71 | 72 | ### Changed 73 | 74 | - Print error instead of crashing when window is not available (SSR) 75 | 76 | ### Changed 77 | 78 | - Properly report plugin name in error messages 79 | 80 | ## 0.0.1 - 2017-01-31 81 | 82 | Initial release 83 | 84 | [0.0.2]: https://github.com/xanf/vuex-shared-mutations/compare/v0.0.1...v0.0.2 85 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vuex-shared-mutations", 3 | "version": "1.0.2", 4 | "description": "Share vuex mutations across tabs via localStorage", 5 | "main": "dist/vuex-shared-mutations.js", 6 | "scripts": { 7 | "test": "karma start --single-run", 8 | "build": "webpack --mode production", 9 | "lint": "eslint src && pretty-quick --staged", 10 | "ci": "npm run build && karma start --single-run" 11 | }, 12 | "files": [ 13 | "dist/vuex-shared-mutations.js", 14 | "dist/vuex-shared-mutations.js.map", 15 | "src", 16 | "LICENSE", 17 | "README.md" 18 | ], 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/xanf/vuex-shared-mutations.git" 22 | }, 23 | "keywords": [ 24 | "vue", 25 | "vuex", 26 | "plugin" 27 | ], 28 | "author": "Illya Klymov ", 29 | "license": "MIT", 30 | "bugs": { 31 | "url": "https://github.com/xanf/vuex-shared-mutations/issues" 32 | }, 33 | "eslintConfig": { 34 | "root": true, 35 | "extends": [ 36 | "airbnb-base", 37 | "prettier" 38 | ], 39 | "env": { 40 | "browser": true 41 | }, 42 | "overrides": [ 43 | { 44 | "files": [ 45 | "*.test.js" 46 | ], 47 | "env": { 48 | "mocha": true 49 | }, 50 | "rules": { 51 | "no-unused-expressions": 0 52 | } 53 | } 54 | ] 55 | }, 56 | "husky": { 57 | "hooks": "npm run lint" 58 | }, 59 | "homepage": "https://github.com/xanf/vuex-shared-mutations#readme", 60 | "devDependencies": { 61 | "@babel/core": "^7.2.2", 62 | "@babel/polyfill": "^7.2.5", 63 | "@babel/preset-env": "^7.3.1", 64 | "babel-loader": "^8.0.5", 65 | "babel-plugin-istanbul": "^5.1.0", 66 | "chai": "^4.2.0", 67 | "core-js": "^2.6.4", 68 | "eslint": "^5.13.0", 69 | "eslint-config-airbnb-base": "^13.1.0", 70 | "eslint-config-prettier": "^4.0.0", 71 | "eslint-plugin-import": "^2.16.0", 72 | "husky": "^1.3.1", 73 | "karma": "^4.0.0", 74 | "karma-browserstack-launcher": "^1.4.0", 75 | "karma-chrome-launcher": "^2.2.0", 76 | "karma-coverage": "^1.1.2", 77 | "karma-mocha": "^1.3.0", 78 | "karma-remap-coverage": "^0.1.5", 79 | "karma-sourcemap-loader": "^0.3.7", 80 | "karma-webpack": "^3.0.5", 81 | "mocha": "^5.2.0", 82 | "prettier": "^1.16.3", 83 | "pretty-quick": "^1.10.0", 84 | "rimraf": "^2.6.3", 85 | "testdouble": "^3.9.3", 86 | "testdouble-chai": "^0.5.0", 87 | "vue": "^2.5.22", 88 | "vuex": "^3.1.0", 89 | "wallaby-webpack": "^3.9.13", 90 | "webpack": "^4.29.0", 91 | "webpack-cli": "^3.2.1" 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/strategies/localStorage.js: -------------------------------------------------------------------------------- 1 | const DEFAULT_KEY = "vuex-shared-mutations"; 2 | 3 | const globalObj = 4 | typeof window !== "undefined" 5 | ? window 6 | : /* istanbul ignore next: node env */ global; 7 | 8 | const MAX_MESSAGE_LENGTH = 4 * 1024; 9 | let messageCounter = 1; 10 | 11 | function splitMessage(message) { 12 | const partsCount = Math.ceil(message.length / MAX_MESSAGE_LENGTH); 13 | return Array.from({ length: partsCount }).map((_, idx) => 14 | message.substr(idx * MAX_MESSAGE_LENGTH, MAX_MESSAGE_LENGTH) 15 | ); 16 | } 17 | 18 | export default class LocalStorageStrategy { 19 | static available( 20 | { window: windowImpl, localStorage: localStorageImpl } = { 21 | window: globalObj.window, 22 | localStorage: globalObj.localStorage 23 | } 24 | ) { 25 | if (!windowImpl || !localStorageImpl) { 26 | return false; 27 | } 28 | 29 | try { 30 | localStorageImpl.setItem("vuex-shared-mutations-test-key", Date.now()); 31 | localStorageImpl.removeItem("vuex-shared-mutations-test-key"); 32 | return true; 33 | } catch (e) { 34 | return false; 35 | } 36 | } 37 | 38 | constructor(options = {}) { 39 | const windowImpl = options.window || globalObj.window; 40 | const localStorageImpl = options.localStorage || globalObj.localStorage; 41 | if ( 42 | !this.constructor.available({ 43 | window: windowImpl, 44 | localStorage: localStorageImpl 45 | }) 46 | ) { 47 | throw new Error("Strategy unavailable"); 48 | } 49 | this.uniqueId = `${Date.now()}-${Math.random()}`; 50 | this.messageBuffer = []; 51 | this.window = windowImpl; 52 | this.storage = localStorageImpl; 53 | this.options = { 54 | key: DEFAULT_KEY, 55 | ...options 56 | }; 57 | } 58 | 59 | // eslint-disable-next-line class-methods-use-this 60 | addEventListener(fn) { 61 | return this.window.addEventListener("storage", event => { 62 | if (!event.newValue) { 63 | return false; 64 | } 65 | 66 | if ( 67 | event.key.indexOf("##") === -1 || 68 | event.key.split("##")[0] !== this.options.key 69 | ) { 70 | return false; 71 | } 72 | const message = this.window.JSON.parse(event.newValue); 73 | /* istanbul ignore next: IE does not follow storage event spec */ 74 | if (message.author === this.uniqueId) { 75 | return false; 76 | } 77 | this.messageBuffer.push(message.messagePart); 78 | if (this.messageBuffer.length === message.total) { 79 | const mutation = this.window.JSON.parse(this.messageBuffer.join("")); 80 | this.messageBuffer = []; 81 | fn(mutation); 82 | } 83 | return true; 84 | }); 85 | } 86 | 87 | share(message) { 88 | const rawMessage = this.window.JSON.stringify(message); 89 | const messageParts = splitMessage(rawMessage); 90 | messageParts.forEach((m, idx) => { 91 | messageCounter += 1; 92 | const key = `${this.options.key}##${idx}`; 93 | this.storage.setItem( 94 | key, 95 | JSON.stringify({ 96 | author: this.uniqueId, 97 | part: idx, 98 | total: messageParts.length, 99 | messagePart: m, 100 | messageCounter 101 | }) 102 | ); 103 | this.storage.removeItem(key); 104 | }); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const webpackConfig = require("./webpack.config"); 3 | 4 | const rule = webpackConfig.module.rules.find(r => 5 | r.include.find(p => p.includes("src")) 6 | ); 7 | 8 | if (!rule.use.options) { 9 | rule.use.options = {}; 10 | } 11 | rule.use.options.plugins = [ 12 | ...(rule.use.options.plugins || []), 13 | // eslint-disable-next-line global-require,import/no-extraneous-dependencies 14 | require("babel-plugin-istanbul") 15 | ]; 16 | 17 | delete webpackConfig.output; 18 | 19 | const customLaunchers = { 20 | bs_chrome: { 21 | base: "BrowserStack", 22 | browser: "Chrome", 23 | browser_version: "72.0", 24 | os: "Windows", 25 | os_version: "10" 26 | }, 27 | bs_firefox: { 28 | base: "BrowserStack", 29 | browser: "Firefox", 30 | browser_version: "65.0", 31 | os: "OS X", 32 | os_version: "Mojave" 33 | }, 34 | bs_safari: { 35 | base: "BrowserStack", 36 | browser: "Safari", 37 | browser_version: "12.0", 38 | os: "OS X", 39 | os_version: "Mojave" 40 | }, 41 | bs_ie_11: { 42 | base: "BrowserStack", 43 | browser: "IE", 44 | browser_version: "11.0", 45 | os: "Windows", 46 | os_version: "8.1" 47 | }, 48 | bs_ie_10: { 49 | base: "BrowserStack", 50 | browser: "IE", 51 | browser_version: "10.0", 52 | os: "Windows", 53 | os_version: "7" 54 | }, 55 | bs_ie_9: { 56 | base: "BrowserStack", 57 | browser: "IE", 58 | browser_version: "9.0", 59 | os: "Windows", 60 | os_version: "7" 61 | } 62 | }; 63 | 64 | function fixMocha(files) { 65 | files.unshift({ 66 | pattern: path.resolve(__dirname, "./node_modules/core-js/client/core.js"), 67 | included: true, 68 | served: true, 69 | watched: false 70 | }); 71 | } 72 | fixMocha.$inject = ["config.files"]; 73 | 74 | module.exports = function karmaConfig(config) { 75 | const browserStack = { 76 | username: process.env.BROWSERSTACK_USERNAME, 77 | accessKey: process.env.BROWSERSTACK_ACCESSKEY 78 | }; 79 | 80 | const reporters = ["progress", "coverage"]; 81 | if (browserStack.username) { 82 | reporters.push("BrowserStack"); 83 | } 84 | 85 | config.set({ 86 | basePath: "", 87 | concurrency: 1, 88 | client: { 89 | useIframe: false, 90 | runInParent: true 91 | }, 92 | frameworks: ["mocha", "inline-mocha-fix"], 93 | plugins: [ 94 | "karma-*", 95 | { 96 | "framework:inline-mocha-fix": ["factory", fixMocha] 97 | } 98 | ], 99 | files: [ 100 | "node_modules/@babel/polyfill/dist/polyfill.js", 101 | "src/**/*.js", 102 | "tests/e2e.test.js", 103 | "./node_modules/vue/dist/vue.min.js", 104 | "./node_modules/vuex/dist/vuex.min.js", 105 | "./dist/vuex-shared-mutations.js", 106 | { pattern: "./tests/fixtures/publishOnWindow.js" }, 107 | { pattern: "./tests/fixtures/testTab.html", included: false } 108 | ], 109 | 110 | exclude: [], 111 | 112 | preprocessors: { 113 | "src/**/*.js": ["webpack"], 114 | "tests/**/*.js": ["webpack"] 115 | }, 116 | 117 | port: 9876, 118 | colors: true, 119 | 120 | logLevel: config.LOG_INFO, 121 | autoWatch: true, 122 | 123 | browserStack, 124 | customLaunchers, 125 | browsers: browserStack.username 126 | ? Object.keys(customLaunchers) 127 | : ["HeadlessChrome"], 128 | customHeaders: [ 129 | { 130 | match: ".*\\.html", 131 | name: "X-UA-Compatible", 132 | value: "IE=edge" 133 | } 134 | ], 135 | singleRun: false, 136 | webpack: webpackConfig, 137 | reporters, 138 | captureTimeout: 60 * 5 * 1000, 139 | browserNoActivityTimeout: 60 * 5 * 1000 140 | }); 141 | }; 142 | -------------------------------------------------------------------------------- /src/vuexSharedMutations.test.js: -------------------------------------------------------------------------------- 1 | import chai from "chai"; 2 | import td from "testdouble"; 3 | import tdChai from "testdouble-chai"; 4 | import createMutationsSharer from "./vuexSharedMutations"; 5 | 6 | chai.use(tdChai(td)); 7 | 8 | const { expect } = chai; 9 | describe("Vuex shared mutations", () => { 10 | it("should throw an error if predicate function is not supplied", () => { 11 | expect(() => { 12 | createMutationsSharer(); 13 | }).to.throw(Error); 14 | }); 15 | 16 | it("should accept array as predicate", () => { 17 | expect(() => { 18 | createMutationsSharer({ 19 | predicate: ["m-1"] 20 | }); 21 | }).to.not.throw(Error); 22 | }); 23 | 24 | it("should accept function as predicate", () => { 25 | expect(() => { 26 | createMutationsSharer({ 27 | predicate: td.func() 28 | }); 29 | }).to.not.throw(Error); 30 | }); 31 | 32 | it("should share relevant mutation", () => { 33 | let capturedHandler; 34 | const fakeStrategy = { 35 | share: td.func(), 36 | addEventListener: td.func() 37 | }; 38 | 39 | const fakeStore = { 40 | subscribe(fn) { 41 | capturedHandler = fn; 42 | } 43 | }; 44 | 45 | createMutationsSharer({ 46 | predicate: ["m-1"], 47 | strategy: fakeStrategy 48 | })(fakeStore); 49 | 50 | return capturedHandler({ type: "m-1", payload: "lol" }).then(() => { 51 | expect(fakeStrategy.share).to.have.been.called; 52 | }); 53 | }); 54 | 55 | it("should not share irrelevant mutation", () => { 56 | let capturedHandler; 57 | const fakeStrategy = { 58 | share: td.func(), 59 | addEventListener: td.func() 60 | }; 61 | 62 | const fakeStore = { 63 | subscribe(fn) { 64 | capturedHandler = fn; 65 | } 66 | }; 67 | 68 | createMutationsSharer({ 69 | predicate: ["m-1"], 70 | strategy: fakeStrategy 71 | })(fakeStore); 72 | return capturedHandler({ type: "m-2", payload: "lol" }).then(() => { 73 | expect(fakeStrategy.share).not.have.been.called; 74 | }); 75 | }); 76 | 77 | it("should respect predicate function when sharing mutation", () => { 78 | let capturedHandler; 79 | const fakeStrategy = { 80 | share: td.func(), 81 | addEventListener: td.func() 82 | }; 83 | 84 | const fakeStore = { 85 | subscribe(fn) { 86 | capturedHandler = fn; 87 | } 88 | }; 89 | 90 | createMutationsSharer({ 91 | predicate: ({ type }) => ["m-1"].indexOf(type) !== -1, 92 | strategy: fakeStrategy 93 | })(fakeStore); 94 | 95 | return capturedHandler({ type: "m-1", payload: "lol" }).then(() => { 96 | expect(fakeStrategy.share).have.been.called; 97 | }); 98 | }); 99 | 100 | it("should not reshare event received from strategy", () => { 101 | let capturedStrategyHandler; 102 | const share = td.func(); 103 | const fakeStrategy = { 104 | share, 105 | addEventListener: fn => { 106 | capturedStrategyHandler = fn; 107 | } 108 | }; 109 | 110 | let subscription; 111 | const fakeStore = { 112 | subscribe(fn) { 113 | subscription = fn; 114 | }, 115 | commit: td.func() 116 | }; 117 | td.when( 118 | fakeStore.commit(td.matchers.anything(), td.matchers.anything()) 119 | ).thenDo((type, payload) => { 120 | subscription({ type, payload }); 121 | }); 122 | 123 | const predicate = td.func(); 124 | createMutationsSharer({ 125 | predicate: ["d1"], 126 | strategy: fakeStrategy 127 | })(fakeStore); 128 | 129 | capturedStrategyHandler({ type: "d1", payload: {} }); 130 | expect(fakeStore.commit).to.have.been.called; 131 | expect(predicate).not.have.been.called; 132 | expect(share).not.have.been.called; 133 | }); 134 | }); 135 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vuex-shared-mutations 2 | 3 | Share certain [Vuex](http://vuex.vuejs.org/) mutations across multiple tabs/windows. [![NPM version](https://img.shields.io/npm/v/vuex-shared-mutations.svg?style=flat-square)](https://www.npmjs.com/package/vuex-shared-mutations) [![Build Status](https://img.shields.io/travis/xanf/vuex-shared-mutations.svg?style=flat-square)](https://travis-ci.org/xanf/vuex-shared-mutations) [![BrowserStack Status](https://www.browserstack.com/automate/badge.svg?badge_key=eDZyK0F0MlE1RzJuRHJHYVZLYWJIZ0JWdnNxdDM0M256dm1DMVBSVUd5bz0tLUhYN3FteXkvaDROSmtSbmZZREFiYnc9PQ==--9fd2deea21df436f47ded98bcd65032e88012900)](https://www.browserstack.com/automate/public-build/eDZyK0F0MlE1RzJuRHJHYVZLYWJIZ0JWdnNxdDM0M256dm1DMVBSVUd5bz0tLUhYN3FteXkvaDROSmtSbmZZREFiYnc9PQ==--9fd2deea21df436f47ded98bcd65032e88012900) 4 | 5 | - [Basic example](https://qk441m1kmq.codesandbox.io/) 6 | - [Nuxt example](https://98qn583znp.sse.codesandbox.io/) 7 | 8 | ## Installation 9 | 10 | ```bash 11 | $ npm install vuex-shared-mutations 12 | ``` 13 | 14 | ## Usage 15 | 16 | ```js 17 | import createMutationsSharer from "vuex-shared-mutations"; 18 | 19 | const store = new Vuex.Store({ 20 | // ... 21 | plugins: [createMutationsSharer({ predicate: ["mutation1", "mutation2"] })] 22 | }); 23 | ``` 24 | 25 | Same as: 26 | 27 | ```js 28 | import createMutationsSharer from "vuex-shared-mutations"; 29 | 30 | const store = new Vuex.Store({ 31 | // ... 32 | plugins: [ 33 | createMutationsSharer({ 34 | predicate: (mutation, state) => { 35 | const predicate = ["mutation1", "mutation2"]; 36 | // Conditionally trigger other plugins subscription event here to 37 | // have them called only once (in the tab where the commit happened) 38 | // ie. save certain values to localStorage 39 | // pluginStateChanged(mutation, state) 40 | return predicate.indexOf(mutation.type) >= 0; 41 | } 42 | }) 43 | ] 44 | }); 45 | ``` 46 | 47 | ## API 48 | 49 | ### `createMutationsSharer([options])` 50 | 51 | Creates a new instance of the plugin with the given options. The following options 52 | can be provided to configure the plugin for your specific needs: 53 | 54 | - `predicate | (mutation: { type: string, payload: any }, state: any) => boolean>`: Either an array of mutation types to be shared or predicate function, which accepts whole mutation object (and state) and returns `true` if this mutation should be shared. 55 | - `strategy: { addEventListener: (fn: function) => any, share(any) => any }` - strategy is an object which provides two functions: 56 | - `addEventListener` - plugin will subscribe to changes events using this function 57 | - `share` - plugin will call this function when data should be shared 58 | 59 | ## How it works 60 | 61 | Initially, this plugin started as a small plugin to share data between tabs using `localStorage`. But several inconsistencies in Internet Explorer lead to entire plugin rewrite and now it is not tied to localStorage anymore 62 | If you do not supply strategy system will use [BroadcastChannel](https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel) if available and downgrade to localStorage if it fails. 63 | 64 | If you need to configure strategies you can do that by hand, for example: 65 | 66 | ``` 67 | import createMutationsSharer, { BroadcastStrategy } from 'vuex-shared-mutations'; 68 | 69 | const store = new Vuex.Store({ 70 | // ... 71 | plugins: [ 72 | createMutationsSharer({ 73 | predicate: ['m-1'], 74 | strategy: new BroadcastStrategy({ key: 'CHANNEL_NAME' }) 75 | }), 76 | ], 77 | }); 78 | ``` 79 | 80 | Options accepted by `BroadcastStrategy`: - `key: string` - channel name, using for sharing 81 | 82 | Options accepted by `LocalStorageStrategy`: - `key: string` - key, used in localStorage (default: 'vuex-shared-mutations') - `maxMessageLength: number` - In some browsers (hello, Internet Explorer), when you're setting big payload on localStorage, "storage" event is not triggered. This strategy bypasses it by splitting message in chunk. If you do not need to support old browsers, you can increase this number (default: 4096) 83 | 84 | ## Contributing 85 | 86 | - Fork 87 | - `> git clone` 88 | - `> npm install` 89 | - Make your changes 90 | - `> npm run test` (assuming you have Chrome installed in your system) 91 | - `> npm run lint` 92 | - If everything is passing: - Update CHANGELOG.md - Commit and Make a pull request 93 | 94 | ## License 95 | 96 | MIT © [Illya Klymov](https://github.com/xanf) 97 | -------------------------------------------------------------------------------- /tests/e2e.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | import chai from "chai"; 3 | 4 | const { expect } = chai; 5 | const WAIT_DELAY = 10000; 6 | 7 | const TEST_PAGE = "/base/tests/fixtures/testTab.html"; 8 | const openedFrames = []; 9 | 10 | const sleep = ms => new Promise(ok => setTimeout(ok, ms)); 11 | 12 | function openFrame(url) { 13 | const iframe = document.createElement("iframe"); 14 | iframe.src = url; 15 | document.body.appendChild(iframe); 16 | openedFrames.push(iframe); 17 | return iframe.contentWindow; 18 | } 19 | 20 | function closeWindows() { 21 | openedFrames.forEach(f => { 22 | f.parentNode.removeChild(f); 23 | }); 24 | openedFrames.length = 0; 25 | } 26 | 27 | const STEP = 100; 28 | function waitForLoad(w, timeout) { 29 | if (timeout < 0) { 30 | throw new Error("Timeout loading window"); 31 | } 32 | 33 | if (w.loaded) { 34 | return Promise.resolve(w); 35 | } 36 | return sleep(STEP).then(() => waitForLoad(w, timeout - STEP)); 37 | } 38 | 39 | function waitForValue(fn, value, timeout) { 40 | if (timeout < 0) { 41 | throw new Error("Timed out"); 42 | } 43 | if (fn() === value) { 44 | return Promise.resolve(true); 45 | } 46 | return sleep(STEP).then(() => waitForValue(fn, value, timeout - STEP)); 47 | } 48 | 49 | describe("Vuex shared mutations", () => { 50 | beforeEach(() => { 51 | localStorage.clear(); 52 | }); 53 | 54 | afterEach(closeWindows); 55 | it("should share mutation between multiple tabs using BroadcastChannel", function t() { 56 | if (typeof BroadcastChannel === "undefined") { 57 | this.skip(); 58 | } 59 | 60 | this.timeout(WAIT_DELAY * 10); 61 | const firstWindow = openFrame(TEST_PAGE); 62 | const secondWindow = openFrame(TEST_PAGE); 63 | return Promise.all([ 64 | waitForLoad(firstWindow, WAIT_DELAY), 65 | waitForLoad(secondWindow, WAIT_DELAY) 66 | ]).then(() => { 67 | const [firstStore, secondStore] = [firstWindow, secondWindow].map(w => { 68 | if (!w.createStore) { 69 | throw new Error("Missing createStore on window, check fixture"); 70 | } 71 | return w.createStore("BroadcastChannel"); 72 | }); 73 | 74 | firstStore.commit("increment"); 75 | return waitForValue( 76 | () => secondStore.state.count, 77 | firstStore.state.count, 78 | WAIT_DELAY 79 | ); 80 | }); 81 | }); 82 | 83 | it("should share mutation between multiple tabs using localStorage", function t() { 84 | this.timeout(WAIT_DELAY * 10); 85 | const firstWindow = openFrame(TEST_PAGE); 86 | const secondWindow = openFrame(TEST_PAGE); 87 | return Promise.all([ 88 | waitForLoad(firstWindow, WAIT_DELAY), 89 | waitForLoad(secondWindow, WAIT_DELAY) 90 | ]).then(() => { 91 | const [firstStore, secondStore] = [firstWindow, secondWindow].map(w => 92 | w.createStore("localStorage") 93 | ); 94 | 95 | firstStore.commit("increment"); 96 | return waitForValue( 97 | () => secondStore.state.count, 98 | firstStore.state.count, 99 | WAIT_DELAY 100 | ); 101 | }); 102 | }); 103 | 104 | it("should share huge mutation between multiple tabs using localStorage", function t() { 105 | if (typeof localStorage === "undefined") { 106 | this.skip(); 107 | } 108 | 109 | this.timeout(WAIT_DELAY * 20); 110 | const firstWindow = openFrame(TEST_PAGE); 111 | const secondWindow = openFrame(TEST_PAGE); 112 | 113 | return Promise.all([ 114 | waitForLoad(firstWindow, WAIT_DELAY), 115 | waitForLoad(secondWindow, WAIT_DELAY) 116 | ]).then(() => { 117 | const [firstStore, secondStore] = [firstWindow, secondWindow].map(w => 118 | w.createStore("localStorage") 119 | ); 120 | 121 | const HUGE_MESSAGE = "HUGE".repeat(100 * 1024); 122 | firstStore.commit("setMessage", { message: HUGE_MESSAGE }); 123 | expect(firstStore.state.message).to.equal(HUGE_MESSAGE); 124 | return waitForValue( 125 | () => secondStore.state.message, 126 | HUGE_MESSAGE, 127 | WAIT_DELAY * 10 128 | ); 129 | }); 130 | }); 131 | 132 | it("should share mutation between multiple tabs with default strategy", function t() { 133 | this.timeout(WAIT_DELAY * 10); 134 | const firstWindow = openFrame(TEST_PAGE); 135 | const secondWindow = openFrame(TEST_PAGE); 136 | return Promise.all([ 137 | waitForLoad(firstWindow, WAIT_DELAY), 138 | waitForLoad(secondWindow, WAIT_DELAY) 139 | ]).then(() => { 140 | const [firstStore, secondStore] = [firstWindow, secondWindow].map(w => 141 | w.createStore() 142 | ); 143 | 144 | firstStore.commit("increment"); 145 | return waitForValue( 146 | () => secondStore.state.count, 147 | firstStore.state.count, 148 | WAIT_DELAY 149 | ); 150 | }); 151 | }); 152 | }); 153 | -------------------------------------------------------------------------------- /src/strategies/localStorage.test.js: -------------------------------------------------------------------------------- 1 | import chai from "chai"; 2 | import td from "testdouble"; 3 | import tdChai from "testdouble-chai"; 4 | import LocalStorageStrategy from "./localStorage"; 5 | 6 | chai.use(tdChai(td)); 7 | const { expect } = chai; 8 | 9 | describe("LocalStorageStrategy", () => { 10 | it("should report as unavailble if window is undefined", () => { 11 | expect( 12 | LocalStorageStrategy.available({ 13 | window: undefined 14 | }) 15 | ).to.equal(false); 16 | }); 17 | 18 | it("should report as unavailble if localStorage is undefined", () => { 19 | expect( 20 | LocalStorageStrategy.available({ 21 | window: {}, 22 | localStorage: undefined 23 | }) 24 | ).to.equal(false); 25 | }); 26 | 27 | it("should throw if localStorage is not working properly", () => { 28 | expect(() => { 29 | // eslint-disable-next-line no-new 30 | new LocalStorageStrategy({ 31 | window, 32 | localStorage: { 33 | setItem: () => { 34 | throw new Error(); 35 | } 36 | } 37 | }); 38 | }).to.throw(Error); 39 | }); 40 | 41 | it("should call setItem / removeItem on localStorage when share is called", () => { 42 | const dummyStorage = { 43 | setItem: td.func(), 44 | removeItem: td.func() 45 | }; 46 | const key = "TEST-KEY"; 47 | const strategy = new LocalStorageStrategy({ 48 | key, 49 | localStorage: dummyStorage 50 | }); 51 | const message = { demo: "message" }; 52 | strategy.share(message); 53 | td.verify( 54 | dummyStorage.setItem( 55 | td.matchers.contains(key), 56 | td.matchers.contains(`"part":0`) 57 | ) 58 | ); 59 | td.verify(dummyStorage.removeItem(td.matchers.contains(key))); 60 | }); 61 | 62 | it("should call setItem / removeItem multiple times when called on long message", () => { 63 | const dummyStorage = { 64 | setItem: td.func(), 65 | removeItem: td.func() 66 | }; 67 | const key = "TEST-KEY"; 68 | const strategy = new LocalStorageStrategy({ 69 | key, 70 | localStorage: dummyStorage 71 | }); 72 | const message = { demo: "m".repeat(64 * 1024) }; 73 | strategy.share(message); 74 | td.verify( 75 | dummyStorage.setItem( 76 | td.matchers.contains(key), 77 | td.matchers.contains(`"part":1`) 78 | ) 79 | ); 80 | }); 81 | 82 | it("should subscribe to storage event on window", () => { 83 | const dummyWindow = { 84 | addEventListener: td.func() 85 | }; 86 | const dummyFn = () => {}; 87 | 88 | const strategy = new LocalStorageStrategy({ 89 | localStorage: { 90 | setItem: td.func(), 91 | removeItem: td.func() 92 | }, 93 | window: dummyWindow 94 | }); 95 | strategy.addEventListener(dummyFn); 96 | td.verify(dummyWindow.addEventListener("storage", td.matchers.anything())); 97 | }); 98 | 99 | it("should ignore updates of other keys in localStorage", () => { 100 | let handler = null; 101 | 102 | const dummyWindow = { 103 | addEventListener: (key, fn) => { 104 | handler = fn; 105 | } 106 | }; 107 | 108 | const dummyFn = td.func(); 109 | const KEY = "TEST-KEY"; 110 | 111 | const strategy = new LocalStorageStrategy({ 112 | key: KEY, 113 | localStorage: { 114 | setItem: td.func(), 115 | removeItem: td.func() 116 | }, 117 | window: dummyWindow 118 | }); 119 | strategy.addEventListener(dummyFn); 120 | expect( 121 | handler({ 122 | newValue: "1", 123 | key: "some-other-key" 124 | }) 125 | ).to.equal(false); 126 | }); 127 | 128 | it("should not trigger on empty value", () => { 129 | let handler = null; 130 | 131 | const dummyWindow = { 132 | addEventListener: (key, fn) => { 133 | handler = fn; 134 | } 135 | }; 136 | 137 | const dummyFn = td.func(); 138 | const KEY = "TEST-KEY"; 139 | 140 | const strategy = new LocalStorageStrategy({ 141 | key: KEY, 142 | localStorage: { 143 | setItem: td.func(), 144 | removeItem: td.func() 145 | }, 146 | window: dummyWindow 147 | }); 148 | strategy.addEventListener(dummyFn); 149 | expect( 150 | handler({ 151 | newValue: null, 152 | key: KEY 153 | }) 154 | ).to.equal(false); 155 | }); 156 | 157 | it("should correctly parse split message", () => { 158 | let handler = null; 159 | 160 | const dummyWindow = { 161 | addEventListener: (key, fn) => { 162 | handler = fn; 163 | }, 164 | JSON 165 | }; 166 | 167 | const dummyFn = td.func(); 168 | const KEY = "TEST-KEY"; 169 | 170 | const strategy = new LocalStorageStrategy({ 171 | key: KEY, 172 | localStorage: { 173 | setItem: td.func(), 174 | removeItem: td.func() 175 | }, 176 | window: dummyWindow 177 | }); 178 | strategy.addEventListener(dummyFn); 179 | handler({ 180 | newValue: JSON.stringify({ 181 | part: 0, 182 | total: 2, 183 | messagePart: "tr" /* partial of "true" */ 184 | }), 185 | key: `${KEY}##0` 186 | }); 187 | expect(dummyFn).not.have.been.called; 188 | handler({ 189 | newValue: JSON.stringify({ 190 | part: 1, 191 | total: 2, 192 | messagePart: "ue" /* partial of "true" */ 193 | }), 194 | key: `${KEY}##1` 195 | }); 196 | expect(dummyFn).have.been.calledWith(true); 197 | }); 198 | }); 199 | --------------------------------------------------------------------------------