├── .editorconfig ├── .github └── workflows │ ├── cd.yml │ └── ci.yml ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── example.gif ├── example ├── index.css ├── index.html └── index.js ├── index.d.ts ├── index.js ├── package.json ├── test └── index.test.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: CD 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | deploy: 10 | name: Build and deploy 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - run: yarn install 15 | - name: Build 16 | run: yarn run build:example 17 | - name: Deploy 18 | uses: peaceiris/actions-gh-pages@v3 19 | with: 20 | github_token: ${{ secrets.GITHUB_TOKEN }} 21 | publish_dir: ./dist 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | lint: 11 | name: Lint 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - run: yarn install 16 | - run: yarn run lint 17 | 18 | spell: 19 | name: Check words 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v2 23 | - run: yarn install 24 | - run: yarn run spell 25 | 26 | test: 27 | name: Test 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: actions/checkout@v2 31 | - run: yarn install 32 | - run: yarn run test 33 | 34 | size: 35 | name: Check size 36 | runs-on: ubuntu-latest 37 | steps: 38 | - uses: actions/checkout@v2 39 | - run: yarn install 40 | - run: yarn size-limit 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | npm-debug.log 3 | yarn-error.log 4 | 5 | api.md 6 | 7 | dist/ 8 | coverage/ 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .editorconfig 2 | 3 | node_modules/ 4 | npm-debug.log 5 | yarn-error.log 6 | yarn.lock 7 | 8 | api.md 9 | example.gif 10 | 11 | example/ 12 | dist/ 13 | .travis.yml 14 | 15 | test/ 16 | coverage/ 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | This project adheres to [Semantic Versioning](http://semver.org/). 3 | 4 | ## 1.0.2 5 | * Fix SSR. 6 | 7 | ## 1.0.1 8 | * Update dual-publish. 9 | * Reduce size. 10 | 11 | ## 1.0.0 12 | * Fix type definitions. 13 | * Move to named exports. 14 | * Reduce size. 15 | 16 | ## 0.4.1 17 | * Add type definitions. 18 | 19 | ## 0.4.0 20 | * Rename GitHub repository. 21 | 22 | ## 0.3.2 23 | * Fix api. 24 | * Fix test. 25 | 26 | ## 0.3.1 27 | * Fix a one-time dispatched of an events. 28 | * Add test. 29 | 30 | ## 0.3.0 31 | * Add option for filter of callback. 32 | 33 | ## 0.2.2 34 | * Fix safari bug. 35 | 36 | ## 0.2.1 37 | * Fix Edge crash. 38 | * Update documentation. 39 | 40 | ## 0.2.0 41 | * Fix initial state. 42 | * Update documentation. 43 | 44 | ## 0.1.0 45 | * Initial release. 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright 2019 Ivan Menshykov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Storeon Crosstab 2 | 3 | Storeon logo by Anton Lovchikov 5 | 6 | Module for [Storeon] to synchronize actions for browser tabs with filtering of events that need to be synchronized. 7 | 8 | It size is 219 bytes (minified and gzipped) and uses [Size Limit] to control size. 9 | 10 | [Storeon]: https://github.com/storeon/storeon 11 | [Size Limit]: https://github.com/ai/size-limit 12 | 13 | 14 | ## Example 15 | ![Example](example.gif) 16 | 17 | 18 | ## Installation 19 | 20 | ``` 21 | npm install @storeon/crosstab 22 | # or 23 | yarn add @storeon/crosstab 24 | ``` 25 | 26 | 27 | ## Usage 28 | 29 | If you want sync state between tabs of the browser you should import the `crossTab` from `@storeon/crosstab` and add this module to `createStore`. 30 | 31 | ```js 32 | import { createStoreon } from 'storeon' 33 | import { persistState } from '@storeon/localstorage' 34 | import { crossTab } from '@storeon/crosstab' 35 | 36 | const increment = store => { 37 | store.on('@init', () => ({ count: 0, openMenu: false })) 38 | store.on('inc', ({ count }) => ({ count: count + 1 })) 39 | store.on('toggleMenu', ({ openMenu }) => ({ openMenu: !openMenu })) 40 | } 41 | 42 | const store = createStoreon([ 43 | increment, 44 | persistState(), 45 | crossTab({ filter: (event, data) => event !== 'toggleMenu' }) 46 | ]) 47 | 48 | store.on('@changed', (store) => { 49 | document.querySelector('.counter').innerText = store.count 50 | }) 51 | ``` 52 | 53 | 54 | ## API 55 | 56 | ```js 57 | import crossTab from '@storeon/crosstab' 58 | 59 | const moduleCrossTab = crossTab({ 60 | filter: (event, data) => event !== 'dec', 61 | key: 'storeon-crosstab' 62 | }) 63 | ``` 64 | 65 | Function `crossTab` could have options: 66 | 67 | * __key__: key for sync data in local storage. 68 | * __filter__: callback function to filter actions to be synchronized. Should return `true` if need sync this action. Takes parameters of an event name and a data that is sent. 69 | 70 | ## Server-side rendering 71 | 72 | `@storeon/crosstab` is not compatible with server-side rendering since it require `window` to operate. You can exclude it during server-side render process. 73 | 74 | ```js 75 | const store = createStoreon([ 76 | increment, 77 | ...typeof window !== 'undefined' ? [ 78 | crossTab({ filter: (event, data) => event !== 'toggleMenu' }) 79 | ] : [] 80 | ]) 81 | ``` 82 | 83 | ## Sponsor 84 | 85 |

86 | 87 | Sponsored by Evrone 89 | 90 |

91 | 92 | 93 | ## LICENSE 94 | 95 | MIT 96 | -------------------------------------------------------------------------------- /example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storeon/crosstab/57af8ea0baf48c7e070d17abc757cd378168693e/example.gif -------------------------------------------------------------------------------- /example/index.css: -------------------------------------------------------------------------------- 1 | html { 2 | font-size: 100%; 3 | font-weight: 400; 4 | text-size-adjust: 100%; 5 | font-kerning: normal; 6 | } 7 | 8 | body { 9 | display: flex; 10 | height: 100vh; 11 | margin: 0; 12 | } 13 | 14 | .grid { 15 | width: 430px; 16 | box-sizing: border-box; 17 | padding: 15px; 18 | margin: auto; 19 | text-align: center; 20 | } 21 | 22 | .counter { 23 | font-size: 40px; 24 | font-weight: 500; 25 | margin: 0 0 0.25em; 26 | } 27 | 28 | .filter { 29 | font-size: 1.25em; 30 | margin: 0 0 0.5em; 31 | } 32 | 33 | .button { 34 | padding: 10px 15px; 35 | font-size: 14px; 36 | user-select: none; 37 | cursor: pointer; 38 | } 39 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | @storeon/crosstab 7 | 8 | 9 | 10 | 11 |
12 |
0
13 | 14 |
Filter on "+10" button
15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /example/index.js: -------------------------------------------------------------------------------- 1 | var createStoreon = require('storeon').createStoreon 2 | var persistState = require('@storeon/localstorage').persistState 3 | 4 | var crossTab = require('../').crossTab 5 | 6 | // Initial state, reducers and business logic are packed in independent modules 7 | function increment (store) { 8 | store.on('@init', function () { 9 | return { count: 0, trim: true } 10 | }) 11 | 12 | store.on('inc', function (state) { 13 | return { count: state.count + 1 } 14 | }) 15 | 16 | store.on('dec', function (state) { 17 | return { count: state.count - 1 } 18 | }) 19 | 20 | store.on('ten', function (state) { 21 | return { count: state.count + 10 } 22 | }) 23 | } 24 | 25 | // Filter callback 26 | function filter (event) { 27 | return event !== 'ten' 28 | } 29 | 30 | // Create store 31 | var store = createStoreon([ 32 | increment, 33 | persistState(), 34 | crossTab({ filter: filter }) 35 | ]) 36 | 37 | var counter = document.querySelector('.counter') 38 | counter.innerText = store.get().count 39 | 40 | store.on('@changed', function (state) { 41 | counter.innerText = state.count 42 | }) 43 | 44 | document 45 | .querySelector('.inc') 46 | .addEventListener('click', function () { 47 | store.dispatch('inc', { type: 'inc-world' }) 48 | }) 49 | 50 | document 51 | .querySelector('.dec') 52 | .addEventListener('click', function () { 53 | store.dispatch('dec', { type: 'dec-world' }) 54 | }) 55 | 56 | document 57 | .querySelector('.ten') 58 | .addEventListener('click', function () { 59 | store.dispatch('ten') 60 | }) 61 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import { StoreonModule } from 'storeon'; 2 | 3 | type Config = { 4 | key?: string; 5 | filter?: (event: PropertyKey, data?: any) => boolean 6 | } 7 | 8 | export function crossTab(config?: Config): StoreonModule; 9 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | function crossTab (config = {}) { 2 | let key = config.key || 'storeon-crosstab' 3 | 4 | let ignoreNext = false 5 | let ignoreDate = 0 6 | let counter = 0 7 | 8 | return function (store) { 9 | store.on('@dispatch', (_, [eventName, data]) => { 10 | if (eventName[0] === '@') { 11 | return 12 | } 13 | 14 | if (ignoreNext) { 15 | ignoreNext = false 16 | return 17 | } 18 | 19 | if (config.filter && !config.filter(eventName, data)) { 20 | return 21 | } 22 | 23 | try { 24 | ignoreDate = Date.now() + '' + counter++ 25 | localStorage[key] = JSON.stringify([eventName, data, ignoreDate]) 26 | } catch (e) {} 27 | }) 28 | 29 | if (typeof window !== 'undefined') { 30 | window.addEventListener('storage', event => { 31 | if (event.key === key) { 32 | let [eventName, data, ignoreDateFromEvent] = JSON.parse( 33 | event.newValue 34 | ) 35 | 36 | if (ignoreDate !== ignoreDateFromEvent) { 37 | ignoreNext = true 38 | store.dispatch(eventName, data) 39 | } 40 | } 41 | }) 42 | } 43 | } 44 | } 45 | 46 | module.exports = { crossTab } 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@storeon/crosstab", 3 | "version": "1.0.2", 4 | "description": "Module for storeon to sync state at different tabs of the browser", 5 | "main": "./index.js", 6 | "types": "./index.d.ts", 7 | "author": "Ivan Solovev ", 8 | "license": "MIT", 9 | "repository": "storeon/crosstab", 10 | "scripts": { 11 | "check": "yarn run test && yarn run lint && size-limit && yarn run spell", 12 | "lint": "eslint *.js", 13 | "spell": "yaspeller *.md", 14 | "test": "jest --coverage", 15 | "build:example": "parcel build example/index.html --no-cache --no-source-maps --public-url ." 16 | }, 17 | "devDependencies": { 18 | "@logux/eslint-config": "^45.2.1", 19 | "@size-limit/dual-publish": "^4.10.2", 20 | "@size-limit/preset-small-lib": "^4.10.2", 21 | "@storeon/localstorage": "^1.4.0", 22 | "check-dts": "^0.4.4", 23 | "dual-publish": "^1.0.5", 24 | "eslint": "^7.23.0", 25 | "eslint-config-standard": "^16.0.2", 26 | "eslint-plugin-import": "^2.22.1", 27 | "eslint-plugin-jest": "^24.3.3", 28 | "eslint-plugin-node": "^11.1.0", 29 | "eslint-plugin-prefer-let": "^1.1.0", 30 | "eslint-plugin-prettierx": "^0.17.1", 31 | "eslint-plugin-promise": "^4.3.1", 32 | "eslint-plugin-security": "^1.4.0", 33 | "eslint-plugin-standard": "^4.1.0", 34 | "eslint-plugin-unicorn": "^29.0.0", 35 | "jest": "^26.6.3", 36 | "parcel-bundler": "^1.12.4", 37 | "size-limit": "^4.10.2", 38 | "storeon": "^3.1.4", 39 | "yaspeller": "^7.0.0" 40 | }, 41 | "size-limit": [ 42 | { 43 | "import": { 44 | "index.js": "{ crossTab }" 45 | }, 46 | "limit": "219 B" 47 | } 48 | ], 49 | "yaspeller": { 50 | "lang": "en", 51 | "ignoreCapitalization": true, 52 | "dictionary": [ 53 | "storeon", 54 | "versioning", 55 | "crosstab", 56 | "gzipped", 57 | "SSR", 58 | "GitHub" 59 | ] 60 | }, 61 | "eslintConfig": { 62 | "extends": "@logux/eslint-config", 63 | "rules": { 64 | "node/no-unpublished-require": "off", 65 | "unicorn/better-regex": "off", 66 | "unicorn/prefer-optional-catch-binding": "off", 67 | "func-style": "off" 68 | } 69 | }, 70 | "browserslist": [ 71 | "last 2 versions" 72 | ] 73 | } 74 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | let { createStoreon } = require('storeon') 2 | 3 | let { crossTab } = require('../') 4 | 5 | let storageCallback = function () {} 6 | 7 | jest.spyOn(window, 'addEventListener') 8 | .mockImplementation((event, callback) => { 9 | if (event === 'storage') { 10 | storageCallback = callback 11 | } 12 | }) 13 | 14 | jest.spyOn(Date, 'now') 15 | .mockImplementation(() => { 16 | return 1 17 | }) 18 | 19 | beforeAll(() => { 20 | localStorage.clear() 21 | }) 22 | 23 | afterEach(() => { 24 | localStorage.clear() 25 | storageCallback = function () {} 26 | }) 27 | 28 | let defaultStorageKey = 'storeon-crosstab' 29 | 30 | function increment (store) { 31 | store.on('@init', () => { 32 | return { count: 0, trim: true } 33 | }) 34 | 35 | store.on('inc', state => { 36 | return { count: state.count + 1 } 37 | }) 38 | } 39 | 40 | it('saves dispatch actions', () => { 41 | let eventName = 'inc' 42 | let data = { hello: 'world' } 43 | let store = createStoreon([increment, crossTab()]) 44 | 45 | store.dispatch(eventName, data) 46 | 47 | let persistedData = JSON.parse(localStorage[defaultStorageKey]) 48 | 49 | expect(persistedData[0]).toBe(eventName) 50 | expect(persistedData[1]).toEqual(data) 51 | }) 52 | 53 | it('other key for storage', () => { 54 | let eventName = 'inc' 55 | let data = { otherKey: 'yeap' } 56 | let key = 'other' 57 | 58 | let store = createStoreon([increment, crossTab({ key })]) 59 | 60 | store.dispatch(eventName, data) 61 | 62 | let persistedData = JSON.parse(localStorage[key]) 63 | 64 | expect(persistedData[0]).toBe(eventName) 65 | expect(persistedData[1]).toEqual(data) 66 | expect(localStorage[defaultStorageKey]).toBeUndefined() 67 | }) 68 | 69 | it('filtering dispatch actions', () => { 70 | let eventName = 'inc' 71 | let data = { testFilter: 'done?' } 72 | let filter = function (event) { 73 | return event !== eventName 74 | } 75 | 76 | let store = createStoreon([increment, crossTab({ filter })]) 77 | 78 | store.dispatch(eventName, data) 79 | 80 | expect(localStorage[defaultStorageKey]).toBeUndefined() 81 | }) 82 | 83 | it('filtering more dispatch actions', () => { 84 | let eventName = 'inc' 85 | 86 | let dataFirst = { first: 'test' } 87 | let dataSecond = { test: 'two' } 88 | let dataThird = { filterField: 'done?' } 89 | 90 | let filter = function (event, data) { 91 | return !Object.hasOwnProperty.call(data, 'filterField') 92 | } 93 | 94 | let store = createStoreon([increment, crossTab({ filter })]) 95 | 96 | store.dispatch(eventName, dataFirst) 97 | store.dispatch(eventName, dataSecond) 98 | store.dispatch(eventName, dataThird) 99 | 100 | let persistedData = JSON.parse(localStorage[defaultStorageKey]) 101 | 102 | expect(persistedData[0]).toBe(eventName) 103 | expect(persistedData[1]).toEqual(dataSecond) 104 | }) 105 | 106 | it('catch the event', () => { 107 | let eventName = 'inc' 108 | 109 | let store = createStoreon([increment, crossTab()]) 110 | 111 | storageCallback({ 112 | key: eventName, 113 | newValue: null 114 | }) 115 | 116 | let newState = store.get() 117 | newState.count = newState.count + 1 118 | 119 | expect(store.get()).toEqual(newState) 120 | }) 121 | 122 | it('catch the double event', () => { 123 | let eventName = 'inc' 124 | let data = { hello: 'double' } 125 | 126 | let store = createStoreon([increment, crossTab()]) 127 | store.dispatch('inc', data) 128 | 129 | storageCallback({ 130 | key: defaultStorageKey, 131 | newValue: JSON.stringify([ 132 | eventName, 133 | data, 134 | Date.now() + 1 135 | ]) 136 | }) 137 | 138 | let newState = store.get() 139 | 140 | expect(newState.count).toEqual(2) 141 | }) 142 | 143 | it('catch the event and sync event', () => { 144 | let eventName = 'inc' 145 | let data = { hello: 'double' } 146 | 147 | let store = createStoreon([increment, crossTab()]) 148 | store.dispatch('inc', data) 149 | 150 | storageCallback({ 151 | key: defaultStorageKey, 152 | newValue: JSON.stringify([ 153 | eventName, 154 | data, 155 | Date.now() + '' + 0 156 | ]) 157 | }) 158 | 159 | let newState = store.get() 160 | 161 | expect(newState.count).toEqual(1) 162 | }) 163 | --------------------------------------------------------------------------------