├── .editorconfig ├── .github └── workflows │ └── workflow.yml ├── .gitignore ├── .npmrc ├── .prettierrc ├── ISSUE_TEMPLATE.md ├── LICENSE ├── README.md ├── angular.json ├── package-lock.json ├── package.json ├── projects └── ngrx-store-storagesync │ ├── jest.config.js │ ├── ng-package.json │ ├── package.json │ ├── src │ ├── lib │ │ ├── actions.ts │ │ ├── feature-options.ts │ │ ├── mock-storage.ts │ │ ├── rehydrate-state.spec.ts │ │ ├── rehydrate-state.ts │ │ ├── storage-sync-options.ts │ │ ├── storage-sync.spec.ts │ │ ├── storage-sync.ts │ │ ├── sync-with-storage.spec.ts │ │ ├── sync-with-storage.ts │ │ ├── util.spec.ts │ │ └── util.ts │ └── public-api.ts │ ├── tsconfig.lib.json │ ├── tsconfig.lib.prod.json │ └── tsconfig.spec.json ├── renovate.json ├── src ├── app │ ├── app-routing.module.ts │ ├── app.component.html │ ├── app.component.scss │ ├── app.component.ts │ ├── app.module.ts │ ├── components │ │ ├── header │ │ │ ├── header.component.html │ │ │ ├── header.component.scss │ │ │ └── header.component.ts │ │ └── navigation-menu │ │ │ ├── navigation-menu.component.html │ │ │ ├── navigation-menu.component.scss │ │ │ └── navigation-menu.component.ts │ ├── containers │ │ ├── drawer │ │ │ ├── drawer.component.html │ │ │ ├── drawer.component.scss │ │ │ └── drawer.component.ts │ │ └── storage-display │ │ │ ├── storage-display.component.html │ │ │ ├── storage-display.component.scss │ │ │ └── storage-display.component.ts │ ├── modules │ │ └── todo │ │ │ ├── containers │ │ │ └── todo-list │ │ │ │ ├── todo-list.component.html │ │ │ │ ├── todo-list.component.scss │ │ │ │ └── todo-list.component.ts │ │ │ ├── models │ │ │ └── todo.ts │ │ │ ├── store │ │ │ ├── todo.actions.ts │ │ │ ├── todo.reducer.ts │ │ │ └── todo.selectors.ts │ │ │ ├── todo-routing.module.ts │ │ │ └── todo.module.ts │ ├── shared │ │ └── modules │ │ │ └── material │ │ │ └── material.module.ts │ └── store │ │ ├── app.actions.ts │ │ ├── app.reducer.ts │ │ ├── models │ │ └── root-state.ts │ │ ├── storage-sync.reducer.ts │ │ └── store.module.ts ├── favicon.ico ├── index.html ├── main.ts ├── polyfills.ts └── styles.scss ├── tsconfig.app.json ├── tsconfig.json └── tsconfig.spec.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.github/workflows/workflow.yml: -------------------------------------------------------------------------------- 1 | name: workflow 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*.*.*' 7 | branches: 8 | - '**' 9 | merge_group: 10 | pull_request: 11 | branches: 12 | - main 13 | 14 | jobs: 15 | test: 16 | runs-on: ubuntu-latest 17 | env: 18 | TZ: Europe/Amsterdam 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: actions/setup-node@v4 22 | with: 23 | node-version: 22 24 | registry-url: https://registry.npmjs.org/ 25 | - run: | 26 | npm ci --ignore-scripts --legacy-peer-deps 27 | npm run build 28 | npm run test 29 | 30 | publish: 31 | if: startsWith(github.ref, 'refs/tags/') 32 | needs: [test] 33 | runs-on: ubuntu-latest 34 | env: 35 | TZ: Europe/Amsterdam 36 | steps: 37 | - uses: actions/checkout@v4 38 | - uses: actions/setup-node@v4 39 | with: 40 | node-version: 22 41 | registry-url: https://registry.npmjs.org/ 42 | env: 43 | NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} 44 | - run: | 45 | npm ci --ignore-scripts --legacy-peer-deps 46 | npm run build 47 | - run: | 48 | cp README.md dist/ngrx-store-storagesync 49 | cd dist/ngrx-store-storagesync 50 | 51 | version=${{ github.ref_name }} 52 | sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$version\"/" ./package.json 53 | 54 | npm publish 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | **/reports/* 8 | !**/reports/.gitkeep 9 | 10 | # Only exists if Bazel was run 11 | /bazel-out 12 | 13 | # dependencies 14 | /node_modules 15 | 16 | # profiling files 17 | chrome-profiler-events.json 18 | speed-measure-plugin.json 19 | 20 | # IDEs and editors 21 | /.idea 22 | .project 23 | .classpath 24 | .c9/ 25 | *.launch 26 | .settings/ 27 | *.sublime-workspace 28 | 29 | # IDE - VSCode 30 | .vscode/* 31 | !.vscode/settings.json 32 | !.vscode/tasks.json 33 | !.vscode/launch.json 34 | !.vscode/extensions.json 35 | .history/* 36 | 37 | # misc 38 | .angular/ 39 | .firebase 40 | /.sass-cache 41 | /connect.lock 42 | /coverage 43 | /libpeerconnection.log 44 | npm-debug.log 45 | yarn-error.log 46 | testem.log 47 | /typings 48 | 49 | # System Files 50 | .DS_Store 51 | Thumbs.db 52 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" 2 | save-exact=true 3 | package-lock=true 4 | legacy-peer-deps=true 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": false, 3 | "printWidth": 120, 4 | "trailingComma": "none", 5 | "singleQuote": true, 6 | "semi": false, 7 | "disableLanguages": [] 8 | } 9 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | ## I'm submitting a... 8 | 9 | 10 |

11 | [ ] Regression (a behavior that used to work and stopped working in a new release)
12 | [ ] Bug report  
13 | [ ] Performance issue
14 | [ ] Feature request
15 | [ ] Documentation issue or request
16 | [ ] Support request
17 | [ ] Other... Please describe:
18 | 
19 | 20 | ## Current behavior 21 | 22 | 23 | 24 | ## Expected behavior 25 | 26 | 27 | 28 | ## Minimal reproduction of the problem with instructions 29 | 30 | For bug reports please provide the _STEPS TO REPRODUCE_ and if possible a _MINIMAL DEMO_ of the problem via 31 | https://stackblitz.com or similar. 32 | 33 | ## What is the motivation / use case for changing the behavior? 34 | 35 | 36 | 37 | ## Environment 38 | 39 |
40 | 
41 | @ngrx/store version: X.Y.Z
42 | 
43 | Browser:
44 | - [ ] Chrome (desktop) version XX
45 | - [ ] Chrome (Android) version XX
46 | - [ ] Chrome (iOS) version XX
47 | - [ ] Firefox version XX
48 | - [ ] Safari (desktop) version XX
49 | - [ ] Safari (iOS) version XX
50 | - [ ] IE version XX
51 | - [ ] Edge version XX
52 | 
53 | Others:
54 | 
55 | 
56 | 
57 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Lars Kniep 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @larscom/ngrx-store-storagesync 2 | 3 | [![npm-version](https://img.shields.io/npm/v/@larscom/ngrx-store-storagesync.svg?label=npm)](https://www.npmjs.com/package/@larscom/ngrx-store-storagesync) 4 | ![npm](https://img.shields.io/npm/dw/@larscom/ngrx-store-storagesync) 5 | [![license](https://img.shields.io/npm/l/@larscom/ngrx-store-storagesync.svg)](https://github.com/larscom/ngrx-store-storagesync/blob/main/LICENSE) 6 | 7 | > **Highly configurable** state sync library between `localStorage/sessionStorage` and `@ngrx/store` (Angular) 8 | 9 | ## Features 10 | 11 | - ✓ Sync with `localStorage` and `sessionStorage` 12 | - ✓ **Storage** option per feature state, for example: 13 | - feature1 to `sessionStorage` 14 | - feature2 to `localStorage` 15 | - ✓ Exclude **deeply** nested properties 16 | 17 | ## Dependencies 18 | 19 | `@larscom/ngrx-store-storagesync` depends on [@ngrx/store](https://github.com/ngrx/store) and [Angular](https://github.com/angular/angular) 20 | 21 | ## Installation 22 | 23 | ```bash 24 | npm install @larscom/ngrx-store-storagesync 25 | ``` 26 | 27 | Choose the version corresponding to your Angular version 28 | 29 | | @angular/core | @larscom/ngrx-store-storagesync | 30 | | ------------- | ------------------------------- | 31 | | >= 12 | >= 13.0.0 | 32 | | < 12 | <= 6.3.0 | 33 | 34 | ## Usage 35 | 36 | Include `storageSyncReducer` in your meta-reducers array in `StoreModule.forRoot` 37 | 38 | ```ts 39 | import { NgModule } from '@angular/core' 40 | import { BrowserModule } from '@angular/platform-browser' 41 | import { StoreModule } from '@ngrx/store' 42 | import { routerReducer } from '@ngrx/router-store' 43 | import { storageSync } from '@larscom/ngrx-store-storagesync' 44 | import * as fromFeature1 from './feature/reducer' 45 | 46 | export const reducers: ActionReducerMap = { 47 | router: routerReducer, 48 | feature1: fromFeature1.reducer 49 | } 50 | 51 | export function storageSyncReducer(reducer: ActionReducer): ActionReducer { 52 | // provide all feature states within the features array 53 | // features which are not provided, do not get synced 54 | const metaReducer = storageSync({ 55 | features: [ 56 | // save only router state to sessionStorage 57 | { stateKey: 'router', storageForFeature: window.sessionStorage }, 58 | 59 | // exclude key 'success' inside 'auth' and all keys 'loading' inside 'feature1' 60 | { stateKey: 'feature1', excludeKeys: ['auth.success', 'loading'] } 61 | ], 62 | // defaults to localStorage 63 | storage: window.localStorage 64 | }) 65 | 66 | return metaReducer(reducer) 67 | } 68 | 69 | // add storageSyncReducer to metaReducers 70 | const metaReducers: MetaReducer[] = [storageSyncReducer] 71 | 72 | @NgModule({ 73 | imports: [BrowserModule, StoreModule.forRoot(reducers, { metaReducers })] 74 | }) 75 | export class AppModule {} 76 | ``` 77 | 78 | ## Configuration 79 | 80 | ```ts 81 | export interface IStorageSyncOptions { 82 | /** 83 | * By default, states are not synced, provide the feature states you want to sync. 84 | */ 85 | features: IFeatureOptions[] 86 | /** 87 | * Provide the storage type to sync the state to, it can be any storage which implements the 'Storage' interface. 88 | */ 89 | storage: Storage 90 | /** 91 | * Give the state a version. Version will be checked before rehydration. 92 | * 93 | * @examples 94 | * Version from Storage = 1 and Config.version = 2 --> Skip hydration 95 | * 96 | * Version from Storage = undefined and Config.version = 1 --> Skip hydration 97 | * 98 | * Version from Storage = 1 and Config.version = undefined --> Skip hydration 99 | * 100 | * Version from Storage = 1 and Config.version = 1 --> Hydrate 101 | */ 102 | version?: number 103 | /** 104 | * Under which key the version should be saved into storage 105 | */ 106 | versionKey?: string 107 | /** 108 | * Function that gets executed on a storage error 109 | * @param error the error that occurred 110 | */ 111 | storageError?: (error: any) => void 112 | /** 113 | * Restore last known state from storage on startup 114 | */ 115 | rehydrate?: boolean 116 | /** 117 | * Serializer for storage keys 118 | * @param key the storage item key 119 | */ 120 | storageKeySerializer?: (key: string) => string 121 | /** 122 | * Custom state merge function after rehydration (by default it does a deep merge) 123 | * @param state the next state 124 | * @param rehydratedState the state resolved from a storage location 125 | */ 126 | rehydrateStateMerger?: (state: T, rehydratedState: T) => T 127 | } 128 | ``` 129 | 130 | ```ts 131 | export interface IFeatureOptions { 132 | /** 133 | * The name of the feature state to sync 134 | */ 135 | stateKey: string 136 | /** 137 | * Filter out (ignore) properties that exist on the feature state. 138 | * 139 | * @example 140 | * // Filter/ignore key with name 'config' and name 'auth' 141 | * ['config', 'auth'] 142 | * 143 | * // Filter/ignore only key 'loading' inside object 'auth' 144 | * ['auth.loading'] 145 | */ 146 | excludeKeys?: string[] 147 | /** 148 | * Provide the storage type to sync the feature state to, 149 | * it can be any storage which implements the 'Storage' interface. 150 | * 151 | * It will override the storage property in StorageSyncOptions 152 | * @see IStorageSyncOptions 153 | */ 154 | storageForFeature?: Storage 155 | /** 156 | * Sync to storage will only occur when this function returns true 157 | * @param featureState the next feature state 158 | * @param state the next state 159 | */ 160 | shouldSync?: (featureState: T[keyof T], state: T) => boolean 161 | /** 162 | * Serializer for storage keys (feature state), 163 | * it will override the storageKeySerializer in StorageSyncOptions 164 | * @see IStorageSyncOptions 165 | * 166 | * @param key the storage item key 167 | */ 168 | storageKeySerializerForFeature?: (key: string) => string 169 | /** 170 | * Serializer for the feature state (before saving to a storage location) 171 | * @param featureState the next feature state 172 | */ 173 | serialize?: (featureState: T[keyof T]) => string 174 | /** 175 | * Deserializer for the feature state (after getting the state from a storage location) 176 | * 177 | * ISO Date objects which are stored as a string gets revived as Date object by default. 178 | * @param featureState the feature state retrieved from a storage location 179 | */ 180 | deserialize?: (featureState: string) => T[keyof T] 181 | } 182 | ``` 183 | 184 | ## Examples 185 | 186 | ### Sync to different storage locations 187 | 188 | You can sync to different storage locations per feature state. 189 | 190 | ```ts 191 | export function storageSyncReducer(reducer: ActionReducer) { 192 | return storageSync({ 193 | features: [ 194 | { stateKey: 'feature1', storageForFeature: window.sessionStorage }, // to sessionStorage 195 | { stateKey: 'feature2' } // to localStorage because of 'default' which is set below. 196 | ], 197 | storage: window.localStorage // default 198 | })(reducer) 199 | } 200 | ``` 201 | 202 | ### Exclude specific properties on state 203 | 204 | Prevent specific properties from being synced to storage. 205 | 206 | ```ts 207 | const state: IRootState = { 208 | feature1: { 209 | message: 'hello', // excluded 210 | loading: false, 211 | auth: { 212 | loading: false, // excluded 213 | loggedIn: false, 214 | message: 'hello' // excluded 215 | } 216 | } 217 | } 218 | 219 | export function storageSyncReducer(reducer: ActionReducer) { 220 | return storageSync({ 221 | features: [{ stateKey: 'feature1', excludeKeys: ['auth.loading', 'message'] }], 222 | storage: window.localStorage 223 | })(reducer) 224 | } 225 | ``` 226 | 227 | ### Sync conditionally 228 | 229 | Sync state to storage based on a condition. 230 | 231 | ```ts 232 | const state: IRootState = { 233 | checkMe: true, // <--- 234 | feature1: { 235 | rememberMe: false, // <--- 236 | auth: { 237 | loading: false, 238 | message: 'hello' 239 | } 240 | } 241 | } 242 | 243 | export function storageSyncReducer(reducer: ActionReducer) { 244 | return storageSync({ 245 | features: [ 246 | { 247 | stateKey: 'feature1', 248 | shouldSync: (feature1, state) => { 249 | return feature1.rememberMe || state.checkMe 250 | } 251 | } 252 | ], 253 | storage: window.localStorage 254 | })(reducer) 255 | } 256 | ``` 257 | 258 | ### Serialize state 259 | 260 | Override the default serializer for the feature state. 261 | 262 | ```ts 263 | export function storageSyncReducer(reducer: ActionReducer) { 264 | return storageSync({ 265 | features: [ 266 | { 267 | stateKey: 'feature1', 268 | serialize: (feature1) => JSON.stringify(feature1) 269 | } 270 | ], 271 | storage: window.localStorage 272 | })(reducer) 273 | } 274 | ``` 275 | 276 | ### Deserialize state 277 | 278 | Override the default deserializer for the feature state. 279 | 280 | ```ts 281 | export function storageSyncReducer(reducer: ActionReducer) { 282 | return storageSync({ 283 | features: [ 284 | { 285 | stateKey: 'feature1', 286 | deserialize: (feature1: string) => JSON.parse(feature1) 287 | } 288 | ], 289 | storage: window.localStorage 290 | })(reducer) 291 | } 292 | ``` 293 | 294 | ### Serialize storage key 295 | 296 | Override the default storage key serializer. 297 | 298 | ```ts 299 | export function storageSyncReducer(reducer: ActionReducer) { 300 | return storageSync({ 301 | features: [{ stateKey: 'feature1' }], 302 | storageKeySerializer: (key: string) => `abc_${key}`, 303 | storage: window.localStorage 304 | })(reducer) 305 | } 306 | ``` 307 | 308 | ### Merge rehydrated state 309 | 310 | Override the default rehydrated state merger. 311 | 312 | ```ts 313 | export function storageSyncReducer(reducer: ActionReducer) { 314 | return storageSync({ 315 | features: [{ stateKey: 'feature1' }], 316 | rehydrateStateMerger: (state: IRootState, rehydratedState: IRootState) => { 317 | return { ...state, ...rehydratedState } 318 | }, 319 | storage: window.localStorage 320 | })(reducer) 321 | } 322 | ``` 323 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "ngrx-store-storagesync-app": { 7 | "projectType": "application", 8 | "schematics": { 9 | "@schematics/angular:component": { 10 | "style": "scss" 11 | } 12 | }, 13 | "root": "", 14 | "sourceRoot": "src", 15 | "prefix": "app", 16 | "architect": { 17 | "build": { 18 | "builder": "@angular/build:application", 19 | "options": { 20 | "outputPath": "dist/ngrx-store-storagesync-app", 21 | "index": "src/index.html", 22 | "browser": "src/main.ts", 23 | "polyfills": ["zone.js"], 24 | "tsConfig": "tsconfig.app.json", 25 | "inlineStyleLanguage": "scss", 26 | "assets": ["src/favicon.ico", "src/assets"], 27 | "styles": ["@angular/material/prebuilt-themes/indigo-pink.css", "src/styles.scss"], 28 | "scripts": [] 29 | }, 30 | "configurations": { 31 | "production": { 32 | "budgets": [ 33 | { 34 | "type": "initial", 35 | "maximumWarning": "500kb", 36 | "maximumError": "1mb" 37 | }, 38 | { 39 | "type": "anyComponentStyle", 40 | "maximumWarning": "2kb", 41 | "maximumError": "4kb" 42 | } 43 | ], 44 | "outputHashing": "all" 45 | }, 46 | "development": { 47 | "optimization": false, 48 | "extractLicenses": false, 49 | "sourceMap": true 50 | } 51 | }, 52 | "defaultConfiguration": "production" 53 | }, 54 | "serve": { 55 | "builder": "@angular/build:dev-server", 56 | "configurations": { 57 | "production": { 58 | "buildTarget": "ngrx-store-storagesync-app:build:production" 59 | }, 60 | "development": { 61 | "buildTarget": "ngrx-store-storagesync-app:build:development" 62 | } 63 | }, 64 | "defaultConfiguration": "development" 65 | }, 66 | "extract-i18n": { 67 | "builder": "@angular/build:extract-i18n", 68 | "options": { 69 | "buildTarget": "ngrx-store-storagesync-app:build" 70 | } 71 | }, 72 | "test": { 73 | "builder": "@angular-builders/jest:run", 74 | "options": { 75 | "tsConfig": "tsconfig.spec.json", 76 | "polyfills": ["zone.js", "zone.js/testing"], 77 | "include": ["src/**/*.spec.ts"], 78 | "coverage": true 79 | } 80 | } 81 | } 82 | }, 83 | "ngrx-store-storagesync": { 84 | "projectType": "library", 85 | "root": "projects/ngrx-store-storagesync", 86 | "sourceRoot": "projects/ngrx-store-storagesync/src", 87 | "prefix": "lib", 88 | "architect": { 89 | "build": { 90 | "builder": "@angular/build:ng-packagr", 91 | "options": { 92 | "project": "projects/ngrx-store-storagesync/ng-package.json" 93 | }, 94 | "configurations": { 95 | "production": { 96 | "tsConfig": "projects/ngrx-store-storagesync/tsconfig.lib.prod.json" 97 | }, 98 | "development": { 99 | "tsConfig": "projects/ngrx-store-storagesync/tsconfig.lib.json" 100 | } 101 | }, 102 | "defaultConfiguration": "production" 103 | }, 104 | "test": { 105 | "builder": "@angular-builders/jest:run", 106 | "options": { 107 | "tsConfig": "tsconfig.spec.json", 108 | "polyfills": ["zone.js", "zone.js/testing"], 109 | "include": ["src/**/*.spec.ts"], 110 | "coverage": true 111 | } 112 | } 113 | } 114 | } 115 | }, 116 | "schematics": { 117 | "@schematics/angular:component": { 118 | "type": "component" 119 | }, 120 | "@schematics/angular:directive": { 121 | "type": "directive" 122 | }, 123 | "@schematics/angular:service": { 124 | "type": "service" 125 | }, 126 | "@schematics/angular:guard": { 127 | "typeSeparator": "." 128 | }, 129 | "@schematics/angular:interceptor": { 130 | "typeSeparator": "." 131 | }, 132 | "@schematics/angular:module": { 133 | "typeSeparator": "." 134 | }, 135 | "@schematics/angular:pipe": { 136 | "typeSeparator": "." 137 | }, 138 | "@schematics/angular:resolver": { 139 | "typeSeparator": "." 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ngrx-store-storagesync", 3 | "version": "14.2.2", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "test": "ng test ngrx-store-storagesync", 8 | "build": "ng build ngrx-store-storagesync" 9 | }, 10 | "private": true, 11 | "dependencies": { 12 | "@angular/animations": "20.0.1", 13 | "@angular/cdk": "20.0.2", 14 | "@angular/common": "20.0.1", 15 | "@angular/compiler": "20.0.1", 16 | "@angular/core": "20.0.1", 17 | "@angular/forms": "20.0.1", 18 | "@angular/material": "20.0.2", 19 | "@angular/platform-browser": "20.0.1", 20 | "@angular/platform-browser-dynamic": "20.0.1", 21 | "@angular/router": "20.0.1", 22 | "@ngrx/store": "19.2.1", 23 | "@ngrx/store-devtools": "19.2.1", 24 | "ramda": "0.30.1", 25 | "rxjs": "7.8.2", 26 | "tslib": "2.8.1", 27 | "zone.js": "0.15.1" 28 | }, 29 | "devDependencies": { 30 | "@angular-builders/jest": "19.0.1", 31 | "@angular/build": "^20.0.1", 32 | "@angular/cli": "20.0.1", 33 | "@angular/compiler-cli": "20.0.1", 34 | "@types/jest": "29.5.14", 35 | "@types/ramda": "0.30.2", 36 | "jest": "29.7.0", 37 | "jest-preset-angular": "14.6.0", 38 | "ng-packagr": "20.0.0", 39 | "typescript": "5.8.3" 40 | } 41 | } -------------------------------------------------------------------------------- /projects/ngrx-store-storagesync/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'jest-preset-angular', 3 | transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$|@stomp/rx-stomp)'] 4 | } 5 | -------------------------------------------------------------------------------- /projects/ngrx-store-storagesync/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "../../dist/ngrx-store-storagesync", 4 | "lib": { 5 | "entryFile": "src/public-api.ts" 6 | }, 7 | "allowedNonPeerDependencies": ["ramda"] 8 | } 9 | -------------------------------------------------------------------------------- /projects/ngrx-store-storagesync/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@larscom/ngrx-store-storagesync", 3 | "version": "0.0.0", 4 | "description": "Highly configurable state sync library between localStorage/sessionStorage and @ngrx/store (Angular)", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/larscom/ngrx-store-storagesync.git" 8 | }, 9 | "publishConfig": { 10 | "access": "public" 11 | }, 12 | "keywords": [ 13 | "angular", 14 | "ngrx", 15 | "redux", 16 | "store", 17 | "state", 18 | "storage", 19 | "rxjs", 20 | "localstorage", 21 | "sessionstorage", 22 | "reactive" 23 | ], 24 | "author": "Lars Kniep", 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/larscom/ngrx-store-storagesync/issues" 28 | }, 29 | "homepage": "https://github.com/larscom/ngrx-store-storagesync#readme", 30 | "peerDependencies": { 31 | "@ngrx/store": ">=8.0.0", 32 | "@angular/common": ">=8.0.0", 33 | "@angular/core": ">=8.0.0" 34 | }, 35 | "dependencies": { 36 | "ramda": "^0.30.1" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /projects/ngrx-store-storagesync/src/lib/actions.ts: -------------------------------------------------------------------------------- 1 | export const INIT_ACTION = '@ngrx/store/init' 2 | export const INIT_ACTION_EFFECTS = '@ngrx/effects/init' 3 | export const UPDATE_ACTION = '@ngrx/store/update-reducers' 4 | -------------------------------------------------------------------------------- /projects/ngrx-store-storagesync/src/lib/feature-options.ts: -------------------------------------------------------------------------------- 1 | export interface IFeatureOptions { 2 | /** 3 | * The name of the feature state to sync 4 | */ 5 | stateKey: string 6 | /** 7 | * Filter out (ignore) properties that exist on the feature state. 8 | * 9 | * @example 10 | * // Filter/ignore key with name 'config' and name 'auth' 11 | * ['config', 'auth'] 12 | * 13 | * // Filter/ignore only key 'loading' inside object 'auth' 14 | * ['auth.loading'] 15 | */ 16 | excludeKeys?: string[] 17 | /** 18 | * Provide the storage type to sync the feature state to, 19 | * it can be any storage which implements the 'Storage' interface. 20 | * 21 | * It will override the storage property in StorageSyncOptions 22 | * @see IStorageSyncOptions 23 | */ 24 | storageForFeature?: Storage 25 | /** 26 | * Sync to storage will only occur when this function returns true 27 | * @param featureState the next feature state 28 | * @param state the next state 29 | */ 30 | shouldSync?: (featureState: T[keyof T], state: T) => boolean 31 | /** 32 | * Serializer for storage keys (feature state), 33 | * it will override the storageKeySerializer in StorageSyncOptions 34 | * @see IStorageSyncOptions 35 | * 36 | * @param key the storage item key 37 | */ 38 | storageKeySerializerForFeature?: (key: string) => string 39 | /** 40 | * Serializer for the feature state (before saving to a storage location) 41 | * @param featureState the next feature state 42 | */ 43 | serialize?: (featureState: T[keyof T]) => string 44 | /** 45 | * Deserializer for the feature state (after getting the state from a storage location) 46 | * 47 | * ISO Date objects which are stored as a string gets revived as Date object by default. 48 | * @param featureState the feature state retrieved from a storage location 49 | */ 50 | deserialize?: (featureState: string) => T[keyof T] 51 | } 52 | -------------------------------------------------------------------------------- /projects/ngrx-store-storagesync/src/lib/mock-storage.ts: -------------------------------------------------------------------------------- 1 | export class MockStorage implements Storage { 2 | data = Object() 3 | 4 | public get length(): number { 5 | return Object.keys(this.data).length 6 | } 7 | 8 | public clear(): void { 9 | for (const key of Object.keys(this.data)) { 10 | delete this.data[key] 11 | } 12 | } 13 | 14 | public getItem(key: string): string { 15 | return this.data[key] || null 16 | } 17 | 18 | public key(index: number): string { 19 | return Object.keys(this.data)[index] 20 | } 21 | 22 | public removeItem(key: string): void { 23 | delete this.data[key] 24 | } 25 | 26 | public setItem(key: string, data: string): void { 27 | this.data[key] = data 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /projects/ngrx-store-storagesync/src/lib/rehydrate-state.spec.ts: -------------------------------------------------------------------------------- 1 | import { MockStorage } from './mock-storage' 2 | import { IStorageSyncOptions } from './storage-sync-options' 3 | import { rehydrateState } from './rehydrate-state' 4 | 5 | describe('RehydrateState', () => { 6 | let storage: Storage 7 | 8 | beforeEach(() => { 9 | storage = new MockStorage() 10 | }) 11 | 12 | it('should call storageError function on error', () => { 13 | jest.spyOn(storage, 'getItem').mockImplementation(() => { 14 | throw new Error('ERROR') 15 | }) 16 | 17 | const storageErrorSpy = jest.fn() 18 | 19 | const config: IStorageSyncOptions = { 20 | storage, 21 | storageError: storageErrorSpy, 22 | storageKeySerializer: (key: string) => key, 23 | features: [{ stateKey: 'feature1' }] 24 | } 25 | 26 | rehydrateState(config) 27 | 28 | expect(storageErrorSpy).toHaveBeenCalledTimes(1) 29 | }) 30 | 31 | it('should re-throw error if storageError function is not present', () => { 32 | jest.spyOn(storage, 'getItem').mockImplementation(() => { 33 | throw new Error('ERROR') 34 | }) 35 | 36 | const config: IStorageSyncOptions = { 37 | storage, 38 | storageKeySerializer: (key: string) => key, 39 | features: [{ stateKey: 'feature1' }] 40 | } 41 | 42 | expect(() => rehydrateState(config)).toThrow(Error('ERROR')) 43 | }) 44 | 45 | it('should return undefined from rehydration because no features present', () => { 46 | const feature1 = { prop1: false, prop2: 100, prop3: { check: false, random: 1337 } } 47 | const feature2 = { prop1: false, prop2: 200, prop3: { check: false, random: 1337 } } 48 | const feature3 = { prop1: false, prop2: 200, prop3: { check: false, random: 1337 } } 49 | 50 | storage.setItem('feature1', JSON.stringify(feature1)) 51 | storage.setItem('feature2', JSON.stringify(feature2)) 52 | storage.setItem('feature3', JSON.stringify(feature3)) 53 | 54 | const config: IStorageSyncOptions = { 55 | storage, 56 | features: [], 57 | storageKeySerializer: (key: string) => key 58 | } 59 | 60 | expect(storage.length).toEqual(3) 61 | 62 | const rehydratedState = rehydrateState(config) 63 | 64 | expect(rehydratedState).toBeUndefined() 65 | }) 66 | 67 | it('should rehydrate selectively', () => { 68 | const feature1 = { prop1: false, prop2: 100, prop3: { check: false, random: 1337 } } 69 | const feature2 = 'myValue' 70 | const feature3 = { prop1: false, prop2: 200, prop3: { check: false, random: 1337 } } 71 | 72 | storage.setItem('feature1', JSON.stringify(feature1)) 73 | storage.setItem('feature2', JSON.stringify(feature2)) 74 | storage.setItem('feature3', JSON.stringify(feature3)) 75 | 76 | const config: IStorageSyncOptions = { 77 | storage, 78 | features: [{ stateKey: 'feature1' }, { stateKey: 'feature2' }], 79 | storageKeySerializer: (key: string) => key 80 | } 81 | 82 | expect(storage.length).toEqual(3) 83 | 84 | const rehydratedState = rehydrateState(config) 85 | 86 | expect(rehydratedState).toEqual({ feature1, feature2 }) 87 | }) 88 | 89 | it('should rehydrate the application state with custom serializer function for feature', () => { 90 | const storageKeySerializerForFeature = (key: string) => { 91 | return `_${key}_` 92 | } 93 | 94 | const feature1 = { 95 | prop1: false, 96 | prop2: 100, 97 | prop3: { check: false, random: 1337 } 98 | } 99 | 100 | storage.setItem(storageKeySerializerForFeature('feature1'), JSON.stringify(feature1)) 101 | 102 | expect(storage.length).toEqual(1) 103 | 104 | const config: IStorageSyncOptions = { 105 | storage, 106 | features: [{ stateKey: 'feature1', storageKeySerializerForFeature }], 107 | storageKeySerializer: (key: string) => key 108 | } 109 | 110 | const rehydratedState = rehydrateState(config) 111 | 112 | expect(rehydratedState).toEqual({ feature1 }) 113 | }) 114 | 115 | it('should correctly revive dates', () => { 116 | const date = new Date() 117 | const feature1 = { 118 | date, 119 | dateALike: '{"date": "2023-01-01T01:00:00"}', 120 | dateString: '2023-01-01T01:00:00' 121 | } 122 | 123 | storage.setItem('feature1', JSON.stringify(feature1)) 124 | 125 | expect(storage.length).toEqual(1) 126 | 127 | const config: IStorageSyncOptions = { 128 | storage, 129 | features: [{ stateKey: 'feature1' }], 130 | storageKeySerializer: (key: string) => key 131 | } 132 | 133 | const rehydratedState = rehydrateState(config) 134 | 135 | expect(rehydratedState).toEqual({ 136 | feature1: { 137 | date, 138 | dateALike: '{"date": "2023-01-01T01:00:00"}', 139 | dateString: new Date('2023-01-01T01:00:00') 140 | } 141 | }) 142 | }) 143 | 144 | it('should rehydrate the application state from primitive types', () => { 145 | const feature1 = 'myValue' 146 | const feature2 = 3 147 | const feature3 = true 148 | const feature4 = undefined 149 | const feature5 = null 150 | 151 | storage.setItem('feature1', JSON.stringify(feature1)) 152 | storage.setItem('feature2', JSON.stringify(feature2)) 153 | storage.setItem('feature3', JSON.stringify(feature3)) 154 | storage.setItem('feature4', JSON.stringify(feature4)) 155 | storage.setItem('feature5', JSON.stringify(feature5)) 156 | 157 | expect(storage.length).toEqual(5) 158 | 159 | const config: IStorageSyncOptions = { 160 | storage, 161 | features: [ 162 | { stateKey: 'feature1' }, 163 | { stateKey: 'feature2' }, 164 | { stateKey: 'feature3' }, 165 | { stateKey: 'feature4' }, 166 | { stateKey: 'feature5' } 167 | ], 168 | storageKeySerializer: (key: string) => key 169 | } 170 | 171 | const rehydratedState = rehydrateState(config) 172 | 173 | expect(rehydratedState).toEqual({ 174 | feature1, 175 | feature2, 176 | feature3, 177 | feature5 178 | }) 179 | }) 180 | 181 | it('should rehydrate with custom deserialize function', () => { 182 | const feature1 = { prop1: false, prop2: 100 } 183 | 184 | storage.setItem('feature1', JSON.stringify(feature1)) 185 | 186 | expect(storage.length).toEqual(1) 187 | 188 | const config: IStorageSyncOptions = { 189 | storage, 190 | features: [ 191 | { 192 | stateKey: 'feature1', 193 | deserialize: (featureState: string) => { 194 | return { 195 | ...JSON.parse(featureState), 196 | extra: 1 197 | } 198 | } 199 | } 200 | ], 201 | storageKeySerializer: (key: string) => key 202 | } 203 | 204 | const rehydratedState = rehydrateState(config) 205 | 206 | expect(rehydratedState).toEqual({ 207 | feature1: { 208 | ...feature1, 209 | extra: 1 210 | } 211 | }) 212 | }) 213 | }) 214 | -------------------------------------------------------------------------------- /projects/ngrx-store-storagesync/src/lib/rehydrate-state.ts: -------------------------------------------------------------------------------- 1 | import { IStorageSyncOptions } from './storage-sync-options' 2 | import { isPlainObjectAndEmpty } from './util' 3 | 4 | const dateMatcher = /(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/ 5 | 6 | /** 7 | * @internal Restores the resolved state from a storage location 8 | */ 9 | export const rehydrateState = ({ 10 | storage, 11 | storageKeySerializer, 12 | features, 13 | storageError 14 | }: IStorageSyncOptions): T | undefined => { 15 | const rehydratedState = features.reduce((acc, curr) => { 16 | const { storageKeySerializerForFeature, stateKey, deserialize, storageForFeature } = curr 17 | 18 | const key = storageKeySerializerForFeature 19 | ? storageKeySerializerForFeature(stateKey) 20 | : storageKeySerializer!(stateKey) 21 | 22 | try { 23 | const featureState = storageForFeature ? storageForFeature.getItem(key) : storage.getItem(key) 24 | return featureState 25 | ? { 26 | ...acc, 27 | ...{ 28 | [stateKey]: deserialize 29 | ? deserialize(featureState) 30 | : JSON.parse(featureState, (_: string, value: any) => { 31 | return dateMatcher.test(String(value)) && !isNaN(Date.parse(value)) ? new Date(value) : value 32 | }) 33 | } 34 | } 35 | : acc 36 | } catch (e) { 37 | if (storageError) { 38 | storageError(e) 39 | } else { 40 | throw e 41 | } 42 | } 43 | }, Object()) as T 44 | 45 | return !isPlainObjectAndEmpty(rehydratedState) ? rehydratedState : undefined 46 | } 47 | -------------------------------------------------------------------------------- /projects/ngrx-store-storagesync/src/lib/storage-sync-options.ts: -------------------------------------------------------------------------------- 1 | import { IFeatureOptions } from './feature-options' 2 | 3 | export interface IStorageSyncOptions { 4 | /** 5 | * By default, states are not synced, provide the feature states you want to sync. 6 | */ 7 | features: IFeatureOptions[] 8 | /** 9 | * Provide the storage type to sync the state to, it can be any storage which implements the 'Storage' interface. 10 | */ 11 | storage: Storage 12 | /** 13 | * Give the state a version. Version will be checked before rehydration. 14 | * 15 | * @examples 16 | * Version from Storage = 1 and Config.version = 2 --> Skip hydration 17 | * 18 | * Version from Storage = undefined and Config.version = 1 --> Skip hydration 19 | * 20 | * Version from Storage = 1 and Config.version = undefined --> Skip hydration 21 | * 22 | * Version from Storage = 1 and Config.version = 1 --> Hydrate 23 | */ 24 | version?: number 25 | /** 26 | * Under which key the version should be saved into storage 27 | */ 28 | versionKey?: string 29 | /** 30 | * Function that gets executed on a storage error 31 | * @param error the error that occurred 32 | */ 33 | storageError?: (error: any) => void 34 | /** 35 | * Restore last known state from storage on startup 36 | */ 37 | rehydrate?: boolean 38 | /** 39 | * Serializer for storage keys 40 | * @param key the storage item key 41 | */ 42 | storageKeySerializer?: (key: string) => string 43 | /** 44 | * Custom state merge function after rehydration (by default it does a deep merge) 45 | * @param state the next state 46 | * @param rehydratedState the state resolved from a storage location 47 | */ 48 | rehydrateStateMerger?: (state: T, rehydratedState: T) => T 49 | } 50 | -------------------------------------------------------------------------------- /projects/ngrx-store-storagesync/src/lib/storage-sync.spec.ts: -------------------------------------------------------------------------------- 1 | import { Action } from '@ngrx/store' 2 | import { INIT_ACTION } from './actions' 3 | import { MockStorage } from './mock-storage' 4 | import { storageSync } from './storage-sync' 5 | import { IStorageSyncOptions } from './storage-sync-options' 6 | 7 | describe('StorageSync', () => { 8 | let storage: Storage 9 | 10 | beforeEach(() => { 11 | storage = new MockStorage() 12 | }) 13 | 14 | it('should call storageError function on error when compatible version is checked from storage', () => { 15 | jest.spyOn(storage, 'getItem').mockImplementation(() => { 16 | throw new Error('ERROR') 17 | }) 18 | 19 | const feature1 = { prop1: false } 20 | const initialState = { feature1 } 21 | 22 | const reducer = (state = initialState, action: Action) => state 23 | 24 | const storageErrorSpy = jest.fn() 25 | 26 | const config: IStorageSyncOptions = { 27 | version: 1, 28 | features: [{ stateKey: 'feature1' }], 29 | storage, 30 | storageError: storageErrorSpy 31 | } 32 | 33 | const metaReducer = storageSync(config) 34 | 35 | metaReducer(reducer)(initialState, { type: INIT_ACTION }) 36 | 37 | expect(storageErrorSpy).toHaveBeenCalledTimes(1) 38 | }) 39 | 40 | it('should re-throw error when compatible version is checked from storage if storageError function is not present', () => { 41 | jest.spyOn(storage, 'getItem').mockImplementation(() => { 42 | throw new Error('ERROR') 43 | }) 44 | 45 | const feature1 = { prop1: false } 46 | const initialState = { feature1 } 47 | 48 | const reducer = (state = initialState, action: Action) => state 49 | 50 | const config: IStorageSyncOptions = { 51 | version: 1, 52 | features: [{ stateKey: 'feature1' }], 53 | storage 54 | } 55 | 56 | const metaReducer = storageSync(config) 57 | 58 | expect(() => metaReducer(reducer)(initialState, { type: INIT_ACTION })).toThrow(Error('ERROR')) 59 | }) 60 | 61 | it('should call storageError function on error when trying to update version in storage', () => { 62 | jest.spyOn(storage, 'setItem').mockImplementation((key, value) => { 63 | if (key === 'version' && value === '1') { 64 | throw new Error('ERROR') 65 | } 66 | }) 67 | 68 | const feature1 = { prop1: false } 69 | const initialState = { feature1 } 70 | 71 | const reducer = (state = initialState, action: Action) => state 72 | 73 | const storageErrorSpy = jest.fn() 74 | 75 | const config: IStorageSyncOptions = { 76 | version: 1, 77 | versionKey: 'version', 78 | features: [{ stateKey: 'feature1' }], 79 | storage, 80 | storageError: storageErrorSpy 81 | } 82 | 83 | const metaReducer = storageSync(config) 84 | 85 | metaReducer(reducer)(initialState, { type: 'ANY_ACTION' }) 86 | 87 | expect(storageErrorSpy).toHaveBeenCalledTimes(1) 88 | }) 89 | 90 | it('should re-throw error when trying to update version in storage if storageError function is not present', () => { 91 | jest.spyOn(storage, 'setItem').mockImplementation((key, value) => { 92 | if (key === 'ngrx-store-storagesync.version' && value === '1') { 93 | throw new Error('ERROR') 94 | } 95 | }) 96 | 97 | const feature1 = { prop1: false } 98 | const initialState = { feature1 } 99 | 100 | const reducer = (state = initialState, action: Action) => state 101 | 102 | const config: IStorageSyncOptions = { 103 | version: 1, 104 | features: [{ stateKey: 'feature1' }], 105 | storage 106 | } 107 | 108 | const metaReducer = storageSync(config) 109 | 110 | expect(() => metaReducer(reducer)(initialState, { type: 'ANY_ACTION' })).toThrow(Error('ERROR')) 111 | }) 112 | 113 | it('should remove item from storage if version is present in storage but not in config', () => { 114 | const feature1 = { prop1: false } 115 | 116 | const initialState = { feature1 } 117 | 118 | storage.setItem('ngrx-store-storagesync.version', String(1)) 119 | storage.setItem('feature1', JSON.stringify({ prop1: true })) 120 | 121 | const metaReducer = storageSync({ 122 | features: [{ stateKey: 'feature1' }, { stateKey: 'feature2' }], 123 | storage 124 | }) 125 | 126 | const reducer = (state = initialState, action: Action) => state 127 | 128 | expect(storage.getItem('ngrx-store-storagesync.version')).toEqual('1') 129 | 130 | metaReducer(reducer)(initialState, { type: 'ANY_ACTION' }) 131 | 132 | expect(storage.getItem('ngrx-store-storagesync.version')).toBeNull() 133 | }) 134 | 135 | it('should return the initial state if version from storage and version from config are present but not the same', () => { 136 | const feature1 = { prop1: false, prop2: 100, prop3: { check: false } } 137 | const feature2 = { prop1: false, prop2: 200, prop3: { check: false } } 138 | 139 | const initialState = { feature1, feature2 } 140 | 141 | storage.setItem('ngrx-store-storagesync.version', String(1)) 142 | storage.setItem('feature1', JSON.stringify({ prop1: true })) 143 | storage.setItem('feature2', JSON.stringify({ prop1: true, prop3: { check: true } })) 144 | 145 | const reducer = (state = initialState, action: Action) => state 146 | 147 | const metaReducer = storageSync({ 148 | version: 2, 149 | features: [{ stateKey: 'feature1' }, { stateKey: 'feature2' }], 150 | storage 151 | }) 152 | 153 | const finalState = metaReducer(reducer)(initialState, { type: INIT_ACTION }) 154 | 155 | expect(finalState).toEqual(initialState) 156 | }) 157 | 158 | it('should return the initial state if version from storage is undefined and version from config is present', () => { 159 | const feature1 = { prop1: false, prop2: 100, prop3: { check: false } } 160 | const feature2 = { prop1: false, prop2: 200, prop3: { check: false } } 161 | 162 | const initialState = { feature1, feature2 } 163 | 164 | storage.setItem('feature1', JSON.stringify({ prop1: true })) 165 | storage.setItem('feature2', JSON.stringify({ prop1: true, prop3: { check: true } })) 166 | 167 | const reducer = (state = initialState, action: Action) => state 168 | 169 | const metaReducer = storageSync({ 170 | version: 1, 171 | features: [{ stateKey: 'feature1' }, { stateKey: 'feature2' }], 172 | storage 173 | }) 174 | 175 | const finalState = metaReducer(reducer)(initialState, { type: INIT_ACTION }) 176 | 177 | expect(finalState).toEqual(initialState) 178 | }) 179 | 180 | it('should return the initial state if version from storage is present and version from config is undefined', () => { 181 | const feature1 = { prop1: false, prop2: 100, prop3: { check: false } } 182 | const feature2 = { prop1: false, prop2: 200, prop3: { check: false } } 183 | 184 | const initialState = { feature1, feature2 } 185 | 186 | storage.setItem('ngrx-store-storagesync.version', String(1)) 187 | storage.setItem('feature1', JSON.stringify({ prop1: true })) 188 | storage.setItem('feature2', JSON.stringify({ prop1: true, prop3: { check: true } })) 189 | 190 | const reducer = (state = initialState, action: Action) => state 191 | 192 | const metaReducer = storageSync({ 193 | features: [{ stateKey: 'feature1' }, { stateKey: 'feature2' }], 194 | storage 195 | }) 196 | 197 | const finalState = metaReducer(reducer)(initialState, { type: INIT_ACTION }) 198 | expect(finalState).toEqual(initialState) 199 | }) 200 | 201 | it('should merge with hydrated state if version from storage is the same as the version from config', () => { 202 | const feature1 = { prop1: false, prop2: 100, prop3: { check: false } } 203 | const feature2 = { prop1: false, prop2: 200, prop3: { check: false } } 204 | 205 | const initialState = { feature1, feature2 } 206 | 207 | storage.setItem('ngrx-store-storagesync.version', String(1)) 208 | storage.setItem('feature1', JSON.stringify({ prop1: true })) 209 | storage.setItem('feature2', JSON.stringify({ prop1: true, prop3: { check: true } })) 210 | 211 | const reducer = (state = initialState, action: Action) => state 212 | 213 | const metaReducer = storageSync({ 214 | version: 1, 215 | features: [{ stateKey: 'feature1' }, { stateKey: 'feature2' }], 216 | storage 217 | }) 218 | 219 | const finalState = metaReducer(reducer)(initialState, { type: INIT_ACTION }) 220 | 221 | const expected = { 222 | feature1: { prop1: true, prop2: 100, prop3: { check: false } }, 223 | feature2: { prop1: true, prop2: 200, prop3: { check: true } } 224 | } 225 | 226 | expect(finalState).toEqual(expected) 227 | }) 228 | 229 | it('should deep merge the initialState and rehydrated state', () => { 230 | const feature1 = { prop1: false, prop2: 100, prop3: { check: false } } 231 | const feature2 = { prop1: false, prop2: 200, prop3: { check: false } } 232 | 233 | const initialState = { feature1, feature2 } 234 | 235 | storage.setItem('feature1', JSON.stringify({ prop1: true })) 236 | storage.setItem('feature2', JSON.stringify({ prop1: true, prop3: { check: true } })) 237 | 238 | const reducer = (state = initialState, action: Action) => state 239 | 240 | const metaReducer = storageSync({ 241 | features: [{ stateKey: 'feature1' }, { stateKey: 'feature2' }], 242 | storage 243 | }) 244 | 245 | const finalState = metaReducer(reducer)(initialState, { type: INIT_ACTION }) 246 | 247 | const expected = { 248 | feature1: { prop1: true, prop2: 100, prop3: { check: false } }, 249 | feature2: { prop1: true, prop2: 200, prop3: { check: true } } 250 | } 251 | 252 | expect(finalState).toEqual(expected) 253 | }) 254 | 255 | it('should deep merge the initialState and rehydrated state from different storage locations', () => { 256 | const storageForFeature = new MockStorage() 257 | 258 | const feature1 = { prop1: false, prop2: 100, prop3: { check: false } } 259 | const feature2 = { prop1: false, prop2: 200, prop3: { check: false } } 260 | 261 | const initialState = { feature1, feature2 } 262 | 263 | storage.setItem('feature1', JSON.stringify({ prop1: true })) 264 | storageForFeature.setItem('feature2', JSON.stringify({ prop1: true, prop3: { check: true } })) 265 | 266 | const reducer = (state = initialState, action: Action) => state 267 | 268 | const metaReducer = storageSync({ 269 | features: [{ stateKey: 'feature1' }, { stateKey: 'feature2', storageForFeature }], 270 | storage 271 | }) 272 | 273 | const finalState = metaReducer(reducer)(initialState, { type: INIT_ACTION }) 274 | 275 | const expected = { 276 | feature1: { prop1: true, prop2: 100, prop3: { check: false } }, 277 | feature2: { prop1: true, prop2: 200, prop3: { check: true } } 278 | } 279 | 280 | expect(finalState).toEqual(expected) 281 | }) 282 | 283 | it('should get the initial state when rehydrate is disabled', () => { 284 | const feature1 = { prop1: false, prop2: 100 } 285 | const feature2 = { prop1: false, prop2: 200 } 286 | 287 | const initialState = { feature1, feature2 } 288 | 289 | storage.setItem('feature1', JSON.stringify({ prop1: true })) 290 | storage.setItem('feature2', JSON.stringify({ prop1: true })) 291 | 292 | const reducer = (state = initialState, action: Action) => state 293 | 294 | const metaReducer = storageSync({ 295 | features: [{ stateKey: 'feature1' }, { stateKey: 'feature2' }], 296 | storage, 297 | rehydrate: false 298 | }) 299 | 300 | const finalState = metaReducer(reducer)(initialState, { type: INIT_ACTION }) 301 | expect(finalState).toEqual(initialState) 302 | }) 303 | 304 | it('should get the initial state when no features are defined', () => { 305 | const feature1 = { prop1: false, prop2: 100 } 306 | const feature2 = { prop1: false, prop2: 200 } 307 | 308 | const initialState = { feature1, feature2 } 309 | 310 | storage.setItem('feature1', JSON.stringify({ prop1: true })) 311 | storage.setItem('feature2', JSON.stringify({ prop1: true })) 312 | 313 | const reducer = (state = initialState, action: Action) => state 314 | 315 | const metaReducer = storageSync({ 316 | features: [], 317 | storage 318 | }) 319 | 320 | const finalState = metaReducer(reducer)(initialState, { type: INIT_ACTION }) 321 | expect(finalState).toEqual(initialState) 322 | }) 323 | 324 | it('should get the feature states as number and string', () => { 325 | const feature1 = 0 326 | const feature2 = null 327 | 328 | const initialState = { feature1, feature2 } 329 | 330 | storage.setItem('feature1', JSON.stringify(1337)) 331 | storage.setItem('feature2', JSON.stringify('myValue')) 332 | 333 | const reducer = (state = initialState, action: Action) => state 334 | 335 | const metaReducer = storageSync({ 336 | features: [ 337 | { 338 | stateKey: 'feature1' 339 | }, 340 | { 341 | stateKey: 'feature2' 342 | } 343 | ], 344 | storage 345 | }) 346 | 347 | const finalState = metaReducer(reducer)(initialState, { type: INIT_ACTION }) 348 | 349 | const expected = { feature1: 1337, feature2: 'myValue' } 350 | 351 | expect(finalState).toEqual(expected) 352 | }) 353 | 354 | it('should merge the nextstate and rehydrated state by using a custom rehydrateStateMerger', () => { 355 | const feature1 = { prop1: false, prop2: 100 } 356 | const feature2 = { prop1: false, prop2: 200 } 357 | 358 | const initialState = { feature1, feature2 } 359 | 360 | storage.setItem('feature1', JSON.stringify({ prop1: true })) 361 | storage.setItem('feature2', JSON.stringify({ prop1: true })) 362 | 363 | const reducer = (state = initialState, action: Action) => state 364 | 365 | const metaReducer = storageSync({ 366 | features: [{ stateKey: 'feature1' }, { stateKey: 'feature2' }], 367 | storage, 368 | rehydrateStateMerger: (state, rehydratedState) => { 369 | return { ...state, ...rehydratedState } 370 | } 371 | }) 372 | 373 | const finalState = metaReducer(reducer)(initialState, { type: INIT_ACTION }) 374 | 375 | const expected = { feature1: { prop1: true }, feature2: { prop1: true } } 376 | 377 | expect(finalState).toEqual(expected) 378 | }) 379 | 380 | it('should merge the initialState and rehydrated state including initial values from the initial state', () => { 381 | const feature1 = { prop1: true, prop2: true } 382 | 383 | const initialState = { feature1 } 384 | 385 | storage.setItem('feature1', JSON.stringify({ prop2: false })) 386 | 387 | const reducer = (state = initialState, action: Action) => state 388 | 389 | const metaReducer = storageSync({ 390 | features: [{ stateKey: 'feature1', excludeKeys: ['prop1'] }], 391 | storage 392 | }) 393 | 394 | const finalState = metaReducer(reducer)(initialState, { type: INIT_ACTION }) 395 | 396 | const expected = { 397 | feature1: { prop1: true, prop2: false } 398 | } 399 | 400 | expect(finalState).toEqual(expected) 401 | }) 402 | }) 403 | -------------------------------------------------------------------------------- /projects/ngrx-store-storagesync/src/lib/storage-sync.ts: -------------------------------------------------------------------------------- 1 | import { Action } from '@ngrx/store' 2 | import { mergeDeepRight } from 'ramda' 3 | import { INIT_ACTION, INIT_ACTION_EFFECTS, UPDATE_ACTION } from './actions' 4 | import { rehydrateState } from './rehydrate-state' 5 | import { IStorageSyncOptions } from './storage-sync-options' 6 | import { syncWithStorage } from './sync-with-storage' 7 | 8 | /** 9 | * The StorageSync Meta Reducer for @ngrx/store. 10 | * 11 | * @param options The configuration for the meta reducer 12 | * 13 | * Check out github for more information. 14 | * @see https://github.com/larscom/ngrx-store-storagesync 15 | * 16 | * @returns the meta reducer function 17 | */ 18 | export const storageSync = 19 | (options: IStorageSyncOptions) => 20 | (reducer: (state: T | undefined, action: Action) => T): ((state: T | undefined, action: Action) => T) => { 21 | const config: IStorageSyncOptions = { 22 | rehydrate: true, 23 | storageKeySerializer: (key: string) => key, 24 | rehydrateStateMerger: (nextState, rehydratedState) => mergeDeepRight(nextState, rehydratedState), 25 | ...options 26 | } 27 | 28 | const { rehydrate, rehydrateStateMerger } = config 29 | 30 | const shouldRehydrate = rehydrate! && isCompatibleVersion(config) 31 | const rehydratedState = shouldRehydrate ? rehydrateState(config) : undefined 32 | 33 | return (state: T | undefined, action: Action): T => { 34 | const nextState = action.type === INIT_ACTION ? reducer(state, action) : ({ ...state } as T) 35 | const shouldMerge = rehydratedState !== undefined && [INIT_ACTION, UPDATE_ACTION].includes(action.type) 36 | const mergedState = reducer(shouldMerge ? rehydrateStateMerger!(nextState, rehydratedState) : nextState, action) 37 | 38 | if (![INIT_ACTION, INIT_ACTION_EFFECTS].includes(action.type)) { 39 | updateNewVersion(config) 40 | syncWithStorage(mergedState, config) 41 | } 42 | 43 | return mergedState 44 | } 45 | } 46 | 47 | /** 48 | * @internal Load version from storage to see if it matches the 49 | * version from the config 50 | * 51 | * @examples 52 | * Storage.version = 1 and Config.version = 2 --> incompatible, skip hydration 53 | * 54 | * Storage.version = undefined and Config.version = 1 --> incompatible, skip hydration 55 | * 56 | * Storage.version = 1 and Config.version = undefined --> unknown, incompatible, skip hydration 57 | * 58 | * Storage.version = 1 and Config.version = 1 --> compatible, hydrate 59 | */ 60 | const isCompatibleVersion = ({ 61 | storage, 62 | storageError, 63 | storageKeySerializer, 64 | version, 65 | versionKey = 'ngrx-store-storagesync.version' 66 | }: IStorageSyncOptions): boolean => { 67 | const key = storageKeySerializer!(versionKey) 68 | try { 69 | const item = storage!.getItem(key) 70 | if (item == null && version == null) { 71 | return true 72 | } 73 | 74 | return Number(item) === version 75 | } catch (e) { 76 | if (storageError) { 77 | storageError(e) 78 | } else { 79 | throw e 80 | } 81 | } 82 | 83 | return false 84 | } 85 | 86 | /** 87 | * @internal Update Storage with new config version 88 | * Remove item from Storage if version from config is undefined 89 | */ 90 | const updateNewVersion = ({ 91 | storage, 92 | storageError, 93 | storageKeySerializer, 94 | version, 95 | versionKey = 'ngrx-store-storagesync.version' 96 | }: IStorageSyncOptions): void => { 97 | const key = storageKeySerializer!(versionKey) 98 | try { 99 | if (version) { 100 | storage!.setItem(key, String(version)) 101 | } else { 102 | storage!.removeItem(key) 103 | } 104 | } catch (e) { 105 | if (storageError) { 106 | storageError(e) 107 | } else { 108 | throw e 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /projects/ngrx-store-storagesync/src/lib/sync-with-storage.spec.ts: -------------------------------------------------------------------------------- 1 | import { MockStorage } from './mock-storage' 2 | import { IStorageSyncOptions } from './storage-sync-options' 3 | import { syncWithStorage } from './sync-with-storage' 4 | 5 | describe('SyncWithStorage', () => { 6 | let storage: Storage 7 | 8 | beforeEach(() => { 9 | storage = new MockStorage() 10 | }) 11 | 12 | it('should call storageError function on error', () => { 13 | jest.spyOn(storage, 'setItem').mockImplementation(() => { 14 | throw new Error('ERROR') 15 | }) 16 | 17 | const storageErrorSpy = jest.fn() 18 | 19 | const config: IStorageSyncOptions = { 20 | storage, 21 | storageError: storageErrorSpy, 22 | storageKeySerializer: (key: string) => key, 23 | features: [{ stateKey: 'feature1' }] 24 | } 25 | 26 | const feature1 = 'myValue' 27 | const state = { feature1 } 28 | 29 | // sync to storage 30 | syncWithStorage(state, config) 31 | 32 | expect(storageErrorSpy).toHaveBeenCalledTimes(1) 33 | }) 34 | 35 | it('should re-throw error if storageError function is not present', () => { 36 | jest.spyOn(storage, 'setItem').mockImplementation(() => { 37 | throw new Error('ERROR') 38 | }) 39 | 40 | const config: IStorageSyncOptions = { 41 | storage, 42 | storageKeySerializer: (key: string) => key, 43 | features: [{ stateKey: 'feature1' }] 44 | } 45 | 46 | const feature1 = 'myValue' 47 | const state = { feature1 } 48 | 49 | // sync to storage 50 | expect(() => syncWithStorage(state, config)).toThrow(Error('ERROR')) 51 | }) 52 | 53 | it('should sync primitive/non-primitive types', () => { 54 | const feature1 = 'myValue' 55 | const feature2 = 3 56 | const feature3 = false 57 | const feature4 = true 58 | const feature5 = ['test'] 59 | const feature6 = undefined 60 | const feature7 = null 61 | const feature8 = { test: false } 62 | 63 | const state = { feature1, feature2, feature3, feature4, feature5, feature6, feature7, feature8 } 64 | 65 | const config: IStorageSyncOptions = { 66 | storage, 67 | storageKeySerializer: (key: string) => key, 68 | features: [ 69 | { 70 | stateKey: 'feature1' 71 | }, 72 | { 73 | stateKey: 'feature2' 74 | }, 75 | { 76 | stateKey: 'feature3' 77 | }, 78 | { 79 | stateKey: 'feature4' 80 | }, 81 | { 82 | stateKey: 'feature5' 83 | }, 84 | { 85 | stateKey: 'feature6' 86 | }, 87 | { 88 | stateKey: 'feature7' 89 | }, 90 | { 91 | stateKey: 'feature8' 92 | } 93 | ] 94 | } 95 | 96 | expect(storage.length).toEqual(0) 97 | 98 | // sync to storage 99 | syncWithStorage(state, config) 100 | 101 | expect(storage.length).toEqual(7) 102 | 103 | expect(JSON.parse(storage.getItem('feature1')!)).toEqual(feature1) 104 | expect(JSON.parse(storage.getItem('feature2')!)).toEqual(feature2) 105 | expect(JSON.parse(storage.getItem('feature3')!)).toEqual(feature3) 106 | expect(JSON.parse(storage.getItem('feature4')!)).toEqual(feature4) 107 | expect(JSON.parse(storage.getItem('feature5')!)).toEqual(feature5) 108 | expect(storage.getItem('feature6')).toBeNull() 109 | expect(JSON.parse(storage.getItem('feature7')!)).toEqual(feature7) 110 | expect(JSON.parse(storage.getItem('feature8')!)).toEqual(feature8) 111 | }) 112 | 113 | it('should not sync if feature state is not present in state', () => { 114 | const feature1 = { checkMe: true, prop1: false, prop2: 100, prop3: { check: false } } 115 | 116 | const state = { feature1 } 117 | 118 | const config: IStorageSyncOptions = { 119 | storage, 120 | storageKeySerializer: (key: string) => key, 121 | features: [ 122 | { 123 | stateKey: 'feature1' 124 | }, 125 | { 126 | stateKey: 'feature2' 127 | } 128 | ] 129 | } 130 | 131 | expect(storage.length).toEqual(0) 132 | 133 | // sync to storage 134 | syncWithStorage(state, config) 135 | 136 | expect(storage.length).toEqual(1) 137 | 138 | expect(JSON.parse(storage.getItem('feature1')!)).toEqual(feature1) 139 | expect(storage.getItem('feature2')).toBeNull() 140 | }) 141 | 142 | it('should not sync if shouldSync condition on a feature state returns false', () => { 143 | const feature1 = { checkMe: true, prop1: false, prop2: 100, prop3: { check: false } } 144 | const feature2 = { checkMe: false, prop2: 200, prop3: { check: false } } 145 | 146 | const state = { feature1, feature2 } 147 | 148 | const config: IStorageSyncOptions = { 149 | storage, 150 | storageKeySerializer: (key: string) => key, 151 | features: [ 152 | { 153 | stateKey: 'feature1', 154 | shouldSync: (featureState, nextState) => { 155 | return featureState.checkMe && nextState.feature1.checkMe 156 | } 157 | }, 158 | { 159 | stateKey: 'feature2', 160 | shouldSync: (featureState, nextState) => { 161 | return featureState.checkMe && nextState.feature2.checkMe 162 | } 163 | } 164 | ] 165 | } 166 | 167 | expect(storage.length).toEqual(0) 168 | 169 | // sync to storage 170 | syncWithStorage(state, config) 171 | 172 | expect(storage.length).toEqual(1) 173 | 174 | expect(JSON.parse(storage.getItem('feature1')!)).toEqual(feature1) 175 | expect(storage.getItem('feature2')).toBeNull() 176 | }) 177 | 178 | it('should selectively sync parts of the feature states with nested keys', () => { 179 | const feature1 = { 180 | prop1: false, 181 | random: 1337, 182 | check: false, 183 | prop3: { 184 | check: false, 185 | random: 1337, 186 | prop5: { check: true, value: 100, prop6: { check: false, value: 200, prop1: true } } 187 | }, 188 | prop4: { check: false, random: 1337 } 189 | } 190 | 191 | const feature2 = { 192 | prop1: false, 193 | random: 1337, 194 | check: false, 195 | prop3: { 196 | check: false, 197 | random: 1337, 198 | prop5: { check: true, value: 100, prop6: { check: false, value: 200, prop1: true } } 199 | }, 200 | prop4: { check: false, random: 1337 } 201 | } 202 | 203 | const state = { feature1, feature2 } 204 | 205 | const config: IStorageSyncOptions = { 206 | storage, 207 | storageKeySerializer: (key: string) => key, 208 | features: [ 209 | { stateKey: 'feature1', excludeKeys: ['prop5.check', 'prop6.check', 'prop1'] }, 210 | { stateKey: 'feature2', excludeKeys: ['prop5.check', 'prop6.check', 'prop1'] } 211 | ] 212 | } 213 | 214 | expect(storage.length).toEqual(0) 215 | 216 | // sync to storage 217 | syncWithStorage(state, config) 218 | 219 | expect(storage.length).toEqual(2) 220 | 221 | expect(JSON.parse(storage.getItem('feature1')!)).toEqual({ 222 | random: 1337, 223 | check: false, 224 | prop3: { 225 | check: false, 226 | random: 1337, 227 | prop5: { value: 100, prop6: { value: 200 } } 228 | }, 229 | prop4: { check: false, random: 1337 } 230 | }) 231 | 232 | expect(JSON.parse(storage.getItem('feature2')!)).toEqual({ 233 | random: 1337, 234 | check: false, 235 | prop3: { 236 | check: false, 237 | random: 1337, 238 | prop5: { value: 100, prop6: { value: 200 } } 239 | }, 240 | prop4: { check: false, random: 1337 } 241 | }) 242 | }) 243 | 244 | it('should not sync empty objects to the provided storage but keep empty arrays', () => { 245 | const feature1 = { prop1: false, array: ['1'], prop2: { check: false } } 246 | const feature2 = { prop1: false, prop2: { check: false, array: [] } } 247 | const state = { feature1, feature2 } 248 | 249 | const config: IStorageSyncOptions = { 250 | storage, 251 | storageKeySerializer: (key: string) => key, 252 | features: [ 253 | { stateKey: 'feature1', excludeKeys: ['prop1', 'check', 'array'] }, 254 | { stateKey: 'feature2', excludeKeys: ['check'] } 255 | ] 256 | } 257 | 258 | expect(storage.length).toEqual(0) 259 | 260 | // sync to storage 261 | syncWithStorage(state, config) 262 | 263 | expect(storage.length).toEqual(1) 264 | 265 | expect(JSON.parse(storage.getItem('feature1')!)).toBeNull() 266 | expect(JSON.parse(storage.getItem('feature2')!)).toEqual({ prop1: false, prop2: { array: [] } }) 267 | }) 268 | 269 | it('should sync the complete state to the provided storage', () => { 270 | const feature1 = { prop1: false, prop2: 100, array: [1, 2, 3], prop3: { check: false } } 271 | const feature2 = { prop1: false, prop2: 200, array: [1, 2, 3], prop3: { check: false } } 272 | 273 | const state = { feature1, feature2 } 274 | 275 | const config: IStorageSyncOptions = { 276 | storage, 277 | storageKeySerializer: (key: string) => key, 278 | features: [{ stateKey: 'feature1' }, { stateKey: 'feature2' }] 279 | } 280 | 281 | expect(storage.length).toEqual(0) 282 | 283 | // sync to storage 284 | syncWithStorage(state, config) 285 | 286 | expect(storage.length).toEqual(2) 287 | 288 | expect(JSON.parse(storage.getItem('feature1')!)).toEqual(feature1) 289 | expect(JSON.parse(storage.getItem('feature2')!)).toEqual(feature2) 290 | }) 291 | 292 | it('should sync a single feature of the state to the provided storage', () => { 293 | const feature1 = { prop1: false, prop2: 100, prop3: { check: false } } 294 | const feature2 = { prop1: false, prop2: 200, prop3: { check: false } } 295 | 296 | const state = { feature1, feature2 } 297 | 298 | const config: IStorageSyncOptions = { 299 | storage, 300 | storageKeySerializer: (key: string) => key, 301 | features: [{ stateKey: 'feature1' }] 302 | } 303 | 304 | expect(storage.length).toEqual(0) 305 | 306 | // sync to storage 307 | syncWithStorage(state, config) 308 | 309 | expect(storage.length).toEqual(1) 310 | 311 | expect(JSON.parse(storage.getItem('feature1')!)).toEqual(feature1) 312 | expect(storage.getItem('feature2')).toBeNull() 313 | }) 314 | 315 | it('should sync only a part of a feature of the state to the provided storage', () => { 316 | const feature1 = { 317 | prop1: false, 318 | array: ['check'], 319 | prop2: 100, 320 | prop3: { check: false, random: 1337 } 321 | } 322 | const feature2 = { prop1: false, prop2: 200, prop3: { check: false, random: 1337 } } 323 | 324 | const state = { feature1, feature2 } 325 | 326 | const config: IStorageSyncOptions = { 327 | storage, 328 | storageKeySerializer: (key: string) => key, 329 | features: [{ stateKey: 'feature1', excludeKeys: ['prop1', 'check'] }] 330 | } 331 | 332 | expect(storage.length).toEqual(0) 333 | 334 | // sync to storage 335 | syncWithStorage(state, config) 336 | 337 | expect(storage.length).toEqual(1) 338 | 339 | const expected = { 340 | array: ['check'], 341 | prop2: 100, 342 | prop3: { random: 1337 } 343 | } 344 | 345 | expect(JSON.parse(storage.getItem('feature1')!)).toEqual(expected) 346 | expect(storage.getItem('feature2')).toBeNull() 347 | }) 348 | 349 | it('should sync the complete state by using a custom storageKeySerializerForFeature', () => { 350 | const storageKeySerializerForFeature = (key: string) => { 351 | return `_${key}_` 352 | } 353 | 354 | const feature1 = { prop1: false, prop2: 100, prop3: { check: false, random: 1337 } } 355 | const feature2 = { prop1: false, prop2: 200, prop3: { check: false, random: 1337 } } 356 | 357 | const state = { feature1, feature2 } 358 | 359 | const config: IStorageSyncOptions = { 360 | storage, 361 | storageKeySerializer: (key: string) => key, 362 | features: [{ stateKey: 'feature1', storageKeySerializerForFeature }, { stateKey: 'feature2' }] 363 | } 364 | 365 | expect(storage.length).toEqual(0) 366 | 367 | // sync to storage 368 | syncWithStorage(state, config) 369 | 370 | expect(storage.length).toEqual(2) 371 | 372 | expect(JSON.parse(storage.getItem(storageKeySerializerForFeature('feature1'))!)).toEqual(feature1) 373 | expect(JSON.parse(storage.getItem('feature2')!)).toEqual(feature2) 374 | }) 375 | 376 | it('should sync the complete state in 2 storage locations with a custom storageKeySerializerForFeature', () => { 377 | const storageForFeature = new MockStorage() 378 | 379 | const storageKeySerializerForFeature = (key: string) => { 380 | return `_${key}_` 381 | } 382 | 383 | const feature1 = { prop1: false, prop2: 100, prop3: { check: false, random: 1337 } } 384 | const feature2 = { prop1: false, prop2: 200, prop3: { check: false, random: 1337 } } 385 | 386 | const state = { feature1, feature2 } 387 | 388 | const config: IStorageSyncOptions = { 389 | storage, 390 | storageKeySerializer: (key: string) => key, 391 | features: [{ stateKey: 'feature1', storageKeySerializerForFeature, storageForFeature }, { stateKey: 'feature2' }] 392 | } 393 | 394 | expect(storage.length).toEqual(0) 395 | expect(storageForFeature.length).toEqual(0) 396 | 397 | // sync to storage 398 | syncWithStorage(state, config) 399 | 400 | expect(storage.length).toEqual(1) 401 | expect(storageForFeature.length).toEqual(1) 402 | 403 | expect(JSON.parse(storageForFeature.getItem(storageKeySerializerForFeature('feature1')))).toEqual(feature1) 404 | expect(JSON.parse(storage.getItem('feature2')!)).toEqual(feature2) 405 | }) 406 | 407 | it('should sync with storage with custom serialize function', () => { 408 | const feature1 = { prop1: false, prop2: 100, prop3: { check: false, random: 1337 } } 409 | 410 | const state = { feature1 } 411 | 412 | const config: IStorageSyncOptions = { 413 | storage, 414 | storageKeySerializer: (key: string) => key, 415 | features: [ 416 | { 417 | stateKey: 'feature1', 418 | serialize: () => { 419 | return JSON.stringify('customSerializer') 420 | } 421 | } 422 | ] 423 | } 424 | 425 | expect(storage.length).toEqual(0) 426 | 427 | // sync to storage 428 | syncWithStorage(state, config) 429 | 430 | expect(storage.length).toEqual(1) 431 | 432 | expect(JSON.parse(storage.getItem('feature1')!)).toEqual('customSerializer') 433 | }) 434 | }) 435 | -------------------------------------------------------------------------------- /projects/ngrx-store-storagesync/src/lib/sync-with-storage.ts: -------------------------------------------------------------------------------- 1 | import { IStorageSyncOptions } from './storage-sync-options' 2 | import { clone } from 'ramda' 3 | import { isObjectLike, isPlainObject, isPlainObjectAndEmpty } from './util' 4 | 5 | /** 6 | * @internal Remove empty objects 7 | */ 8 | const removeEmptyObjects = (object: any): any => { 9 | for (const key in object) { 10 | if (!isPlainObject(object[key])) { 11 | continue 12 | } 13 | 14 | if (!isPlainObjectAndEmpty(object[key])) { 15 | removeEmptyObjects(object[key]) 16 | } 17 | 18 | if (isPlainObjectAndEmpty(object[key])) { 19 | delete object[key] 20 | } 21 | } 22 | 23 | return object 24 | } 25 | 26 | /** 27 | * @internal Exclude properties from featureState 28 | */ 29 | const excludePropsFromState = (featureState: T[keyof T], excludeKeys?: string[]): T[keyof T] => { 30 | if (!excludeKeys || !excludeKeys.length) { 31 | return featureState 32 | } 33 | 34 | const keyPairs = excludeKeys.map((key) => ({ 35 | leftKey: key.split('.')[0], 36 | rightKey: key.split('.')[1] 37 | })) 38 | 39 | for (const key in featureState) { 40 | const keyPair = keyPairs.find((pair) => pair.leftKey === key) 41 | const leftKey = keyPair?.leftKey 42 | const rightKey = keyPair?.rightKey 43 | 44 | if (isObjectLike(featureState[key])) { 45 | if (leftKey && rightKey) { 46 | excludePropsFromState(featureState[key], [...excludeKeys, rightKey]) 47 | } else if (leftKey) { 48 | delete featureState[key] 49 | } else { 50 | excludePropsFromState(featureState[key], excludeKeys) 51 | } 52 | } else if (leftKey) { 53 | delete featureState[key] 54 | } 55 | } 56 | 57 | return removeEmptyObjects(featureState) 58 | } 59 | 60 | /** 61 | * @internal Sync state with storage 62 | */ 63 | export const syncWithStorage = ( 64 | state: T, 65 | { features, storage, storageKeySerializer, storageError }: IStorageSyncOptions 66 | ): void => { 67 | features 68 | .filter(({ stateKey }) => state[stateKey as keyof T] !== undefined) 69 | .filter(({ stateKey, shouldSync }) => (shouldSync ? shouldSync(state[stateKey as keyof T], state) : true)) 70 | .forEach(({ stateKey, excludeKeys, storageKeySerializerForFeature, serialize, storageForFeature }) => { 71 | const featureStateClone = clone(state[stateKey as keyof T]) 72 | const featureState = excludePropsFromState(featureStateClone, excludeKeys) 73 | 74 | if (isPlainObjectAndEmpty(featureState)) { 75 | return 76 | } 77 | 78 | const key = storageKeySerializerForFeature 79 | ? storageKeySerializerForFeature(stateKey) 80 | : storageKeySerializer!(stateKey) 81 | 82 | const value = serialize ? serialize(featureState) : JSON.stringify(featureState) 83 | 84 | try { 85 | if (storageForFeature) { 86 | storageForFeature.setItem(key, value) 87 | } else { 88 | storage.setItem(key, value) 89 | } 90 | } catch (e) { 91 | if (storageError) { 92 | storageError(e) 93 | } else { 94 | throw e 95 | } 96 | } 97 | }) 98 | } 99 | -------------------------------------------------------------------------------- /projects/ngrx-store-storagesync/src/lib/util.spec.ts: -------------------------------------------------------------------------------- 1 | import { isObjectLike, isPlainObject, isPlainObjectAndEmpty } from './util' 2 | 3 | describe('Util', () => { 4 | class Foo {} 5 | 6 | it('should detect plain objects', () => { 7 | expect(isPlainObject({})).toBeTruthy() 8 | expect(isPlainObject({ a: 1 })).toBeTruthy() 9 | 10 | expect(isPlainObject(new Foo())).toBeFalsy() 11 | expect(isPlainObject([1, 2, 3])).toBeFalsy() 12 | expect(isPlainObject(1)).toBeFalsy() 13 | expect(isPlainObject(true)).toBeFalsy() 14 | expect(isPlainObject('value')).toBeFalsy() 15 | expect(isPlainObject(null)).toBeFalsy() 16 | expect(isPlainObject(undefined)).toBeFalsy() 17 | }) 18 | 19 | it('should detect object like', () => { 20 | expect(isObjectLike({})).toBeTruthy() 21 | expect(isObjectLike({ a: 1 })).toBeTruthy() 22 | expect(isObjectLike(new Foo())).toBeTruthy() 23 | expect(isObjectLike([1, 2, 3])).toBeTruthy() 24 | 25 | expect(isObjectLike(1)).toBeFalsy() 26 | expect(isObjectLike(true)).toBeFalsy() 27 | expect(isObjectLike('value')).toBeFalsy() 28 | expect(isObjectLike(null)).toBeFalsy() 29 | expect(isObjectLike(undefined)).toBeFalsy() 30 | }) 31 | 32 | it('should detect plain object and empty', () => { 33 | expect(isPlainObjectAndEmpty({})).toBeTruthy() 34 | 35 | expect(isPlainObjectAndEmpty(new Foo())).toBeFalsy() 36 | expect(isPlainObjectAndEmpty({ a: 1 })).toBeFalsy() 37 | expect(isPlainObjectAndEmpty(null)).toBeFalsy() 38 | expect(isPlainObjectAndEmpty(undefined)).toBeFalsy() 39 | expect(isPlainObjectAndEmpty([])).toBeFalsy() 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /projects/ngrx-store-storagesync/src/lib/util.ts: -------------------------------------------------------------------------------- 1 | export const isObjectLike = (value: any): boolean => typeof value === 'object' && value !== null 2 | 3 | export const isPlainObject = (value: any) => value?.constructor === Object 4 | 5 | export const isPlainObjectAndEmpty = (value: any): boolean => isPlainObject(value) && Object.keys(value).length === 0 6 | -------------------------------------------------------------------------------- /projects/ngrx-store-storagesync/src/public-api.ts: -------------------------------------------------------------------------------- 1 | export { storageSync } from './lib/storage-sync' 2 | export { type IFeatureOptions } from './lib/feature-options' 3 | export { type IStorageSyncOptions } from './lib/storage-sync-options' 4 | -------------------------------------------------------------------------------- /projects/ngrx-store-storagesync/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../out-tsc/lib", 5 | "declaration": true, 6 | "declarationMap": true, 7 | "inlineSources": true, 8 | "types": [] 9 | }, 10 | "exclude": ["**/*.spec.ts", "**/**/mock-storage.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /projects/ngrx-store-storagesync/tsconfig.lib.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.lib.json", 3 | "compilerOptions": { 4 | "declarationMap": false 5 | }, 6 | "angularCompilerOptions": { 7 | "compilationMode": "partial" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /projects/ngrx-store-storagesync/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../out-tsc/spec", 5 | "types": ["jest"] 6 | }, 7 | "include": ["**/*.spec.ts", "**/*.d.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ], 6 | "packageRules": [ 7 | { 8 | "description": "Automerge non-major updates", 9 | "matchUpdateTypes": ["minor", "patch"], 10 | "automerge": true 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /src/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { RouterModule, Routes } from '@angular/router'; 3 | 4 | export const routes: Routes = [ 5 | { 6 | path: 'todo', 7 | pathMatch: 'full', 8 | loadChildren: () => import('./modules/todo/todo.module').then((m) => m.TodoModule) 9 | }, 10 | { path: '**', redirectTo: 'todo' } 11 | ]; 12 | 13 | @NgModule({ 14 | imports: [RouterModule.forRoot(routes, { useHash: true })], 15 | exports: [RouterModule] 16 | }) 17 | export class AppRoutingModule {} 18 | -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | -------------------------------------------------------------------------------- /src/app/app.component.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | flex-direction: column; 4 | } 5 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout'; 2 | import { Component } from '@angular/core'; 3 | import { Store } from '@ngrx/store'; 4 | import { map } from 'rxjs/operators'; 5 | import * as appActions from './store/app.actions'; 6 | import { IRootState } from './store/models/root-state'; 7 | 8 | @Component({ 9 | selector: 'app-root', 10 | templateUrl: 'app.component.html', 11 | styleUrls: ['app.component.scss'], 12 | standalone: false 13 | }) 14 | export class AppComponent { 15 | readonly isHandsetPortrait$ = this.breakpoint 16 | .observe(Breakpoints.HandsetPortrait) 17 | .pipe(map(({ matches }) => matches)); 18 | 19 | constructor(private readonly breakpoint: BreakpointObserver, private readonly store$: Store) {} 20 | 21 | onMenuClicked(): void { 22 | this.store$.dispatch(appActions.toggleDrawer({ open: true })); 23 | } 24 | 25 | onResetState(): void { 26 | if (!confirm('Reset state and reload?')) return; 27 | 28 | window.localStorage.clear(); 29 | window.sessionStorage.clear(); 30 | window.location.reload(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { BrowserModule } from '@angular/platform-browser'; 3 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; 4 | import { AppRoutingModule } from './app-routing.module'; 5 | import { AppComponent } from './app.component'; 6 | import { HeaderComponent } from './components/header/header.component'; 7 | import { NavigationMenuComponent } from './components/navigation-menu/navigation-menu.component'; 8 | import { DrawerComponent } from './containers/drawer/drawer.component'; 9 | import { StorageDisplayComponent } from './containers/storage-display/storage-display.component'; 10 | import { MaterialModule } from './shared/modules/material/material.module'; 11 | import { StoreModule } from './store/store.module'; 12 | 13 | @NgModule({ 14 | declarations: [AppComponent, HeaderComponent, DrawerComponent, NavigationMenuComponent, StorageDisplayComponent], 15 | imports: [BrowserModule, BrowserAnimationsModule, AppRoutingModule, MaterialModule, StoreModule], 16 | bootstrap: [AppComponent] 17 | }) 18 | export class AppModule {} 19 | -------------------------------------------------------------------------------- /src/app/components/header/header.component.html: -------------------------------------------------------------------------------- 1 | 2 | 8 |
9 |

11 | {{ isMobile ? 'ngrx-store-storagesync' : '@larscom/ngrx-store-storagesync' }} 12 |

13 |
14 | 20 | 21 |
22 | -------------------------------------------------------------------------------- /src/app/components/header/header.component.scss: -------------------------------------------------------------------------------- 1 | mat-toolbar { 2 | position: fixed; 3 | z-index: 2; 4 | } 5 | 6 | h1 { 7 | cursor: pointer; 8 | } 9 | 10 | .center { 11 | display: flex; 12 | justify-content: center; 13 | align-items: center; 14 | flex-basis: 100%; 15 | } 16 | -------------------------------------------------------------------------------- /src/app/components/header/header.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-header', 5 | templateUrl: 'header.component.html', 6 | styleUrls: ['header.component.scss'], 7 | changeDetection: ChangeDetectionStrategy.OnPush, 8 | standalone: false 9 | }) 10 | export class HeaderComponent { 11 | @Input() isMobile!: boolean; 12 | 13 | @Output() 14 | navigateHome = new EventEmitter(); 15 | @Output() 16 | menuClicked = new EventEmitter(); 17 | @Output() 18 | resetState = new EventEmitter(); 19 | } 20 | -------------------------------------------------------------------------------- /src/app/components/navigation-menu/navigation-menu.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | list_alt 4 | Todo List 5 | 6 | 7 | 8 |

Try to do a page refresh while this menu is open, state is persisted through sessionStorage.

9 | -------------------------------------------------------------------------------- /src/app/components/navigation-menu/navigation-menu.component.scss: -------------------------------------------------------------------------------- 1 | mat-icon { 2 | margin-right: 10px; 3 | } 4 | 5 | p { 6 | margin-left: 10px; 7 | } 8 | -------------------------------------------------------------------------------- /src/app/components/navigation-menu/navigation-menu.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, EventEmitter, Output } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-navigation-menu', 5 | templateUrl: 'navigation-menu.component.html', 6 | styleUrls: ['navigation-menu.component.scss'], 7 | changeDetection: ChangeDetectionStrategy.OnPush, 8 | standalone: false 9 | }) 10 | export class NavigationMenuComponent { 11 | @Output() navigate = new EventEmitter(); 12 | } 13 | -------------------------------------------------------------------------------- /src/app/containers/drawer/drawer.component.html: -------------------------------------------------------------------------------- 1 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/app/containers/drawer/drawer.component.scss: -------------------------------------------------------------------------------- 1 | mat-drawer-container { 2 | margin-top: 55px; 3 | position: absolute; 4 | top: 0; 5 | bottom: 0; 6 | left: 0; 7 | right: 0; 8 | } 9 | 10 | mat-drawer { 11 | width: 200px; 12 | overflow: hidden; 13 | } 14 | 15 | mat-drawer-content { 16 | margin: 20px 5px 0 5px; 17 | padding: 20px; 18 | overflow-x: hidden; 19 | display: flex; 20 | flex-direction: row-reverse; 21 | justify-content: space-around; 22 | } 23 | -------------------------------------------------------------------------------- /src/app/containers/drawer/drawer.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { Router } from '@angular/router'; 3 | import { select, Store } from '@ngrx/store'; 4 | import * as appActions from '../../store/app.actions'; 5 | import { IRootState } from '../../store/models/root-state'; 6 | 7 | @Component({ 8 | selector: 'app-drawer', 9 | templateUrl: 'drawer.component.html', 10 | styleUrls: ['drawer.component.scss'], 11 | standalone: false 12 | }) 13 | export class DrawerComponent { 14 | readonly drawerOpened$ = this.store$.pipe(select(({ app }) => app.drawerOpen)); 15 | 16 | constructor(private readonly store$: Store, private readonly router: Router) {} 17 | 18 | onBackdropClicked(): void { 19 | this.store$.dispatch(appActions.toggleDrawer({ open: false })); 20 | } 21 | 22 | onNavigate(path: string): void { 23 | this.store$.dispatch(appActions.toggleDrawer({ open: false })); 24 | this.router.navigate([path]); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/app/containers/storage-display/storage-display.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Local Storage

4 | {{ localStorage$ | async }} 5 |
6 |
7 |

Session Storage

8 | {{ sessionStorage$ | async }} 9 |
10 |
11 | -------------------------------------------------------------------------------- /src/app/containers/storage-display/storage-display.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | flex: 0.5; 3 | } 4 | 5 | .storage-display__container { 6 | display: flex; 7 | flex-direction: column; 8 | margin-left: 20px; 9 | } 10 | 11 | code { 12 | white-space: pre-wrap; 13 | } 14 | 15 | .localstorage, 16 | .sessionstorage { 17 | flex: 1; 18 | } 19 | -------------------------------------------------------------------------------- /src/app/containers/storage-display/storage-display.component.ts: -------------------------------------------------------------------------------- 1 | import { isPlatformBrowser } from '@angular/common'; 2 | import { Component, Inject, PLATFORM_ID } from '@angular/core'; 3 | import { Store } from '@ngrx/store'; 4 | import { filter, map } from 'rxjs/operators'; 5 | import { IRootState } from '../../store/models/root-state'; 6 | 7 | @Component({ 8 | selector: 'app-storage-display', 9 | templateUrl: './storage-display.component.html', 10 | styleUrls: ['./storage-display.component.scss'], 11 | standalone: false 12 | }) 13 | export class StorageDisplayComponent { 14 | readonly sessionStorage$ = this.store$.pipe( 15 | filter(() => isPlatformBrowser(this.platformId)), 16 | map(() => JSON.stringify(window.sessionStorage, null, 4)) 17 | ); 18 | readonly localStorage$ = this.store$.pipe( 19 | filter(() => isPlatformBrowser(this.platformId)), 20 | map(() => JSON.stringify(window.localStorage, null, 4)) 21 | ); 22 | 23 | constructor(private readonly store$: Store, @Inject(PLATFORM_ID) private platformId: Object) {} 24 | } 25 | -------------------------------------------------------------------------------- /src/app/modules/todo/containers/todo-list/todo-list.component.html: -------------------------------------------------------------------------------- 1 |
3 |

Todo List

4 | 5 |

Add a todo item below or mark one as complete.

6 |

Try to do a page refresh after, you'll see the data will persist through localStorage.

7 | 8 |
9 | 10 | 14 | 15 | 19 |
20 | 21 | 22 |

Todo ({{ count }})

23 | 24 | 25 | {{ todo.value }} 28 | 29 | 30 | 31 |
32 | 33 |

Nothing to do....

34 |
35 | 36 | 37 |

Completed ({{ count }})

38 | 39 | 40 | {{ todo.value }} 41 | 42 | 43 | 44 |
45 |
46 | -------------------------------------------------------------------------------- /src/app/modules/todo/containers/todo-list/todo-list.component.scss: -------------------------------------------------------------------------------- 1 | .todo-list__container { 2 | max-width: 500px; 3 | } 4 | 5 | .input__container { 6 | display: flex; 7 | margin-top: 50px; 8 | justify-content: space-between; 9 | align-items: center; 10 | } 11 | 12 | button { 13 | margin-left: 10px; 14 | min-width: 100px; 15 | } 16 | 17 | mat-form-field { 18 | width: 100%; 19 | } 20 | 21 | h2, 22 | p { 23 | margin-top: 25px; 24 | } 25 | -------------------------------------------------------------------------------- /src/app/modules/todo/containers/todo-list/todo-list.component.ts: -------------------------------------------------------------------------------- 1 | import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout'; 2 | import { Component, HostListener } from '@angular/core'; 3 | import { select, Store } from '@ngrx/store'; 4 | import { map } from 'rxjs/operators'; 5 | import { IRootState } from '../../../../store/models/root-state'; 6 | import { ITodo } from '../../models/todo'; 7 | import * as todoActions from '../../store/todo.actions'; 8 | import * as todoSelectors from '../../store/todo.selectors'; 9 | 10 | @Component({ 11 | selector: 'app-todo-list', 12 | templateUrl: 'todo-list.component.html', 13 | styleUrls: ['todo-list.component.scss'], 14 | standalone: false 15 | }) 16 | export class TodoListComponent { 17 | readonly todos$ = this.store$.pipe(select(todoSelectors.getTodos)); 18 | readonly count$ = this.store$.pipe(select(todoSelectors.getCount)); 19 | readonly completedTodos$ = this.store$.pipe(select(todoSelectors.getCompletedTodos)); 20 | readonly completedCount$ = this.store$.pipe(select(todoSelectors.getCompletedCount)); 21 | readonly isMobile$ = this.breakpoint.observe(Breakpoints.Handset).pipe(map(({ matches }) => matches)); 22 | 23 | constructor(private readonly store$: Store, private readonly breakpoint: BreakpointObserver) {} 24 | 25 | todo = String(); 26 | 27 | onTodoClicked({ id }: ITodo): void { 28 | setTimeout(() => this.store$.dispatch(todoActions.completeTodo({ id })), 250); 29 | } 30 | 31 | addTodo(): void { 32 | if (this.todo.length) { 33 | this.store$.dispatch(todoActions.addTodo({ todo: { value: this.todo } })); 34 | } 35 | this.todo = String(); 36 | } 37 | 38 | @HostListener('document:keydown.enter') 39 | onEnterPressed(): void { 40 | this.addTodo(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/app/modules/todo/models/todo.ts: -------------------------------------------------------------------------------- 1 | export interface ITodo { 2 | id?: string; 3 | value: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/app/modules/todo/store/todo.actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction, props } from '@ngrx/store'; 2 | 3 | import { ITodo } from '../models/todo'; 4 | 5 | export const addTodo = createAction('[Todo] Add Todo', props<{ todo: ITodo }>()); 6 | export const completeTodo = createAction('[Todo] Complete Todo', props<{ id?: string }>()); 7 | -------------------------------------------------------------------------------- /src/app/modules/todo/store/todo.reducer.ts: -------------------------------------------------------------------------------- 1 | import { createReducer, on } from '@ngrx/store'; 2 | 3 | import { ITodo } from '../models/todo'; 4 | import * as todoActions from './todo.actions'; 5 | 6 | export const initialState: ITodoState = { 7 | todos: [ 8 | { id: '7eb7e000-6f2a-11e9-9de5-a5d4530ecefa', value: 'Buy milk' }, 9 | { id: '8db46470-6f2a-11e9-85fe-4f67c1315e73', value: 'Work on blog' }, 10 | { id: '9200db30-6f2a-11e9-8826-89e87191fac8', value: 'Buy present' } 11 | ], 12 | completed: [] 13 | }; 14 | 15 | export interface ITodoState { 16 | readonly todos: ITodo[]; 17 | readonly completed: string[]; 18 | } 19 | 20 | export const reducer = createReducer( 21 | initialState, 22 | on(todoActions.addTodo, (state, { todo }) => ({ ...state, todos: [...state.todos, { id: crypto.randomUUID(), ...todo }] })), 23 | on(todoActions.completeTodo, (state, { id }) => ({ ...state, completed: [...state.completed, String(id)] })) 24 | ); 25 | -------------------------------------------------------------------------------- /src/app/modules/todo/store/todo.selectors.ts: -------------------------------------------------------------------------------- 1 | import { createFeatureSelector, createSelector } from '@ngrx/store'; 2 | import { ITodoState } from './todo.reducer'; 3 | 4 | export const getTodoState = createFeatureSelector('todo'); 5 | 6 | export const getTodos = createSelector(getTodoState, ({ todos, completed }) => 7 | todos.filter(({ id }) => !completed.includes(String(id))) 8 | ); 9 | export const getCount = createSelector(getTodos, (todos) => todos.length); 10 | 11 | export const getCompletedTodos = createSelector(getTodoState, ({ todos, completed }) => 12 | todos.filter(({ id }) => completed.includes(String(id))) 13 | ); 14 | export const getCompletedCount = createSelector(getCompletedTodos, (completed) => completed.length); 15 | -------------------------------------------------------------------------------- /src/app/modules/todo/todo-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { RouterModule, Routes } from '@angular/router'; 3 | 4 | import { TodoListComponent } from './containers/todo-list/todo-list.component'; 5 | 6 | const routes: Routes = [ 7 | { 8 | path: '', 9 | component: TodoListComponent 10 | } 11 | ]; 12 | 13 | @NgModule({ 14 | imports: [RouterModule.forChild(routes)], 15 | exports: [RouterModule] 16 | }) 17 | export class TodoRoutingModule {} 18 | -------------------------------------------------------------------------------- /src/app/modules/todo/todo.module.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { InjectionToken, NgModule } from '@angular/core'; 3 | import { FormsModule, ReactiveFormsModule } from '@angular/forms'; 4 | import { ActionReducer, StoreModule } from '@ngrx/store'; 5 | 6 | import { MaterialModule } from '../../shared/modules/material/material.module'; 7 | import { TodoListComponent } from './containers/todo-list/todo-list.component'; 8 | import * as fromTodo from './store/todo.reducer'; 9 | import { TodoRoutingModule } from './todo-routing.module'; 10 | 11 | export const TODO_REDUCER = new InjectionToken>('TODO_REDUCER'); 12 | 13 | @NgModule({ 14 | declarations: [TodoListComponent], 15 | imports: [ 16 | StoreModule.forFeature('todo', TODO_REDUCER), 17 | CommonModule, 18 | TodoRoutingModule, 19 | MaterialModule, 20 | FormsModule, 21 | ReactiveFormsModule 22 | ], 23 | providers: [{ provide: TODO_REDUCER, useValue: fromTodo.reducer }] 24 | }) 25 | export class TodoModule {} 26 | -------------------------------------------------------------------------------- /src/app/shared/modules/material/material.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { MatButtonModule } from '@angular/material/button'; 3 | import { MatDividerModule } from '@angular/material/divider'; 4 | import { MatIconModule } from '@angular/material/icon'; 5 | import { MatInputModule } from '@angular/material/input'; 6 | import { MatListModule } from '@angular/material/list'; 7 | import { MatRadioModule } from '@angular/material/radio'; 8 | import { MatSidenavModule } from '@angular/material/sidenav'; 9 | import { MatSlideToggleModule } from '@angular/material/slide-toggle'; 10 | import { MatToolbarModule } from '@angular/material/toolbar'; 11 | 12 | @NgModule({ 13 | exports: [ 14 | MatButtonModule, 15 | MatListModule, 16 | MatDividerModule, 17 | MatToolbarModule, 18 | MatSidenavModule, 19 | MatIconModule, 20 | MatInputModule, 21 | MatRadioModule, 22 | MatSlideToggleModule 23 | ] 24 | }) 25 | export class MaterialModule {} 26 | -------------------------------------------------------------------------------- /src/app/store/app.actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction, props } from '@ngrx/store'; 2 | 3 | export const toggleDrawer = createAction('[App] Toggle Drawer', props<{ open: boolean }>()); 4 | -------------------------------------------------------------------------------- /src/app/store/app.reducer.ts: -------------------------------------------------------------------------------- 1 | import { createReducer, on } from '@ngrx/store'; 2 | import * as appActions from './app.actions'; 3 | 4 | export const initialState: IAppState = { 5 | drawerOpen: false 6 | }; 7 | 8 | export interface IAppState { 9 | readonly drawerOpen: boolean; 10 | } 11 | 12 | export const reducer = createReducer( 13 | initialState, 14 | on(appActions.toggleDrawer, (state, { open: drawerOpen }) => ({ ...state, drawerOpen })) 15 | ); 16 | -------------------------------------------------------------------------------- /src/app/store/models/root-state.ts: -------------------------------------------------------------------------------- 1 | import { ITodoState } from '../../modules/todo/store/todo.reducer'; 2 | import { IAppState } from '../app.reducer'; 3 | 4 | export interface IRootState { 5 | app: IAppState; 6 | todo?: ITodoState; 7 | } 8 | -------------------------------------------------------------------------------- /src/app/store/storage-sync.reducer.ts: -------------------------------------------------------------------------------- 1 | import { storageSync } from '@larscom/ngrx-store-storagesync' 2 | import { ActionReducer } from '@ngrx/store' 3 | import { IRootState } from './models/root-state' 4 | 5 | export function storageSyncReducer(reducer: ActionReducer): ActionReducer { 6 | const metaReducer = storageSync({ 7 | features: [{ stateKey: 'app', storageForFeature: window.sessionStorage }, { stateKey: 'todo' }], 8 | storageError: console.error, 9 | storage: window.localStorage 10 | }) 11 | 12 | return metaReducer(reducer) 13 | } 14 | -------------------------------------------------------------------------------- /src/app/store/store.module.ts: -------------------------------------------------------------------------------- 1 | import { InjectionToken, NgModule } from '@angular/core'; 2 | import { ActionReducerMap, StoreModule as NgRxStoreModule } from '@ngrx/store'; 3 | import { StoreDevtoolsModule } from '@ngrx/store-devtools'; 4 | import * as fromApp from './app.reducer'; 5 | import { IRootState } from './models/root-state'; 6 | import { storageSyncReducer } from './storage-sync.reducer'; 7 | 8 | export const ROOT_REDUCER = new InjectionToken>('ROOT_REDUCER'); 9 | 10 | const strictStore = true; 11 | 12 | @NgModule({ 13 | imports: [ 14 | NgRxStoreModule.forRoot(ROOT_REDUCER, { 15 | metaReducers: [storageSyncReducer], 16 | runtimeChecks: { 17 | strictStateSerializability: strictStore, 18 | strictActionSerializability: strictStore, 19 | strictStateImmutability: strictStore, 20 | strictActionImmutability: strictStore, 21 | strictActionWithinNgZone: strictStore, 22 | strictActionTypeUniqueness: strictStore 23 | } 24 | }), 25 | StoreDevtoolsModule.instrument({ maxAge: 30 }) 26 | ], 27 | exports: [NgRxStoreModule], 28 | providers: [{ provide: ROOT_REDUCER, useValue: { app: fromApp.reducer } }] 29 | }) 30 | export class StoreModule {} 31 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/larscom/ngrx-store-storagesync/6195b767b70157f6ec845096a9c1a34b42c687ce/src/favicon.ico -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | @larscom/ngrx-store-storagesync 7 | 9 | 11 | 13 | 15 | 17 | 19 | 20 | 21 | 23 | 26 | 28 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 2 | 3 | import { enableProdMode, isDevMode } from '@angular/core'; 4 | import { AppModule } from './app/app.module'; 5 | 6 | if (!isDevMode()) { 7 | enableProdMode(); 8 | } 9 | 10 | platformBrowserDynamic() 11 | .bootstrapModule(AppModule) 12 | .catch((err) => console.error(err)); 13 | -------------------------------------------------------------------------------- /src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes recent versions of Safari, Chrome (including 12 | * Opera), Edge on the desktop, and iOS and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/guide/browser-support 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** 22 | * By default, zone.js will patch all possible macroTask and DomEvents 23 | * user can disable parts of macroTask/DomEvents patch by setting following flags 24 | * because those flags need to be set before `zone.js` being loaded, and webpack 25 | * will put import in the top of bundle, so user need to create a separate file 26 | * in this directory (for example: zone-flags.ts), and put the following flags 27 | * into that file, and then add the following code before importing zone.js. 28 | * import './zone-flags'; 29 | * 30 | * The flags allowed in zone-flags.ts are listed here. 31 | * 32 | * The following flags will work for all browsers. 33 | * 34 | * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 35 | * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 36 | * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 37 | * 38 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 39 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 40 | * 41 | * (window as any).__Zone_enable_cross_context_check = true; 42 | * 43 | */ 44 | 45 | /*************************************************************************************************** 46 | * Zone JS is required by default for Angular itself. 47 | */ 48 | import 'zone.js'; // Included with Angular CLI. 49 | 50 | 51 | /*************************************************************************************************** 52 | * APPLICATION IMPORTS 53 | */ 54 | -------------------------------------------------------------------------------- /src/styles.scss: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | box-sizing: border-box; 4 | padding: 0; 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/app", 5 | "types": [] 6 | }, 7 | "files": ["src/main.ts", "src/polyfills.ts"], 8 | "include": ["src/**/*.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "outDir": "./dist/out-tsc", 6 | "forceConsistentCasingInFileNames": true, 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "noImplicitOverride": true, 10 | "noPropertyAccessFromIndexSignature": true, 11 | "noImplicitReturns": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "sourceMap": true, 14 | "paths": { 15 | "@larscom/ngrx-store-storagesync": [ 16 | "projects/ngrx-store-storagesync/src/public-api" 17 | ] 18 | }, 19 | "declaration": false, 20 | "experimentalDecorators": true, 21 | "moduleResolution": "bundler", 22 | "importHelpers": true, 23 | "target": "ES2022", 24 | "module": "ES2022", 25 | "lib": [ 26 | "ES2022", 27 | "dom" 28 | ], 29 | "useDefineForClassFields": false 30 | }, 31 | "angularCompilerOptions": { 32 | "enableI18nLegacyMessageIdFormat": false, 33 | "strictInjectionParameters": true, 34 | "strictInputAccessModifiers": true, 35 | "strictTemplates": true, 36 | "disableTypeScriptVersionCheck": true 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/spec", 5 | "types": ["jest"] 6 | }, 7 | "files": ["src/polyfills.ts"], 8 | "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] 9 | } 10 | --------------------------------------------------------------------------------