├── .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 | [](https://www.npmjs.com/package/@larscom/ngrx-store-storagesync)
4 | 
5 | [](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 |
18 | refresh
19 |
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 | Add
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 |
--------------------------------------------------------------------------------