├── .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 |
12 | We're sorry but vuex-shared-mutations-simple-demo doesn't work properly without JavaScript enabled. Please enable it to continue.
13 |
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 |
2 |
3 |
4 |
5 |
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 |
2 |
3 |
{{ $store.state.count }}
4 |
-
5 |
+
6 |
+2
7 |
8 |
9 | Open this example in
10 | multiple
11 | tabs and play with
12 | + ,
13 | - , and
14 | +2 buttons.
15 |
16 |
17 | + and
18 | - mutations are shared using
19 | vuex-shared-mutations , so the value will be in sync while you're using these buttons
23 |
24 |
25 | +2 mutation, however, is not shared, so you can put your tabs
26 | out-of-sync. This is intentional, we're sharing
27 | mutations , not entire
28 | system state
29 |
30 |
31 |
32 | Demo source
33 |
34 |
35 |
36 |
37 |
42 |
--------------------------------------------------------------------------------
/examples/simple/src/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
{{ $store.state.count }}
4 |
-
5 |
+
6 |
+2
7 |
8 |
9 | Open this example in
10 | multiple
14 | tabs and play with
15 | + ,
16 | - , and
17 | +2 buttons.
18 |
19 |
20 | + and
21 | - mutations are shared using
22 | vuex-shared-mutations , so the value will be in sync while you're using these buttons
26 |
27 |
28 | +2 mutation, however, is not shared, so you can put your tabs
29 | out-of-sync. This is intentional, we're sharing
30 | mutations , not entire
31 | system state
32 |
33 |
34 |
35 | Demo source
36 |
37 |
38 |
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. [](https://www.npmjs.com/package/vuex-shared-mutations) [](https://travis-ci.org/xanf/vuex-shared-mutations) [](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 |
--------------------------------------------------------------------------------