├── .editorconfig ├── .github └── workflows │ └── workflow.yml ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── angular.json ├── package-lock.json ├── package.json ├── projects └── ngrx-signals-storage │ ├── jest.config.js │ ├── ng-package.json │ ├── package.json │ ├── setup-jest.ts │ ├── src │ ├── lib │ │ ├── config.ts │ │ ├── ssr.spec.ts │ │ ├── ssr.ts │ │ ├── with-storage.spec.ts │ │ └── with-storage.ts │ └── public-api.ts │ ├── tsconfig.lib.json │ ├── tsconfig.lib.prod.json │ └── tsconfig.spec.json ├── renovate.json ├── src ├── app │ ├── app.component.html │ ├── app.component.scss │ ├── app.component.ts │ ├── app.config.ts │ └── app.routes.ts ├── assets │ └── .gitkeep ├── favicon.ico ├── index.html ├── main.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 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.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-signals-storage 49 | cd dist/ngrx-signals-storage 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 | /bazel-out 8 | 9 | # Node 10 | /node_modules 11 | npm-debug.log 12 | yarn-error.log 13 | 14 | # IDEs and editors 15 | .idea/ 16 | .project 17 | .classpath 18 | .c9/ 19 | *.launch 20 | .settings/ 21 | *.sublime-workspace 22 | 23 | # Visual Studio Code 24 | .vscode/* 25 | !.vscode/settings.json 26 | !.vscode/tasks.json 27 | !.vscode/launch.json 28 | !.vscode/extensions.json 29 | .history/* 30 | 31 | # Miscellaneous 32 | /.angular/cache 33 | .sass-cache/ 34 | /connect.lock 35 | /coverage 36 | /libpeerconnection.log 37 | testem.log 38 | /typings 39 | 40 | # System files 41 | .DS_Store 42 | Thumbs.db 43 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" 2 | save-exact=true 3 | package-lock=true 4 | legacy-peer-deps=true 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 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-signals-storage 2 | 3 | [![npm-version](https://img.shields.io/npm/v/@larscom/ngrx-signals-storage.svg?label=npm)](https://www.npmjs.com/package/@larscom/ngrx-signals-storage) 4 | ![npm](https://img.shields.io/npm/dw/@larscom/ngrx-signals-storage) 5 | [![license](https://img.shields.io/npm/l/@larscom/ngrx-signals-storage.svg)](https://github.com/larscom/ngrx-signals-storage/blob/main/LICENSE) 6 | 7 | > Save signal state (@ngrx/signals) to localStorage/sessionStorage and restore the state on page load with a single line of code (with SSR support). 8 | 9 | ## Installation 10 | 11 | ```bash 12 | npm install @larscom/ngrx-signals-storage 13 | ``` 14 | 15 | ## Dependencies 16 | 17 | `@larscom/ngrx-signals-storage` depends on [@ngrx/signals](https://ngrx.io/guide/signals/install) and [Angular](https://github.com/angular/angular) 18 | 19 | ## Usage 20 | 21 | ### 1. Default 22 | 23 | Import the `withStorage` function and place it after the `withState` function. Optional configuration can be passed as 3th argument. 24 | 25 | ```ts 26 | import { withStorage } from '@larscom/ngrx-signals-storage' 27 | import { withState, signalStore } from '@ngrx/signals' 28 | 29 | export const CounterStore = signalStore( 30 | withState({ 31 | count: 0 32 | }), 33 | // state will be saved to sessionStorage under the key: 'myKey' 34 | withStorage('myKey', sessionStorage) 35 | ) 36 | ``` 37 | 38 | ### 2. SSR (Server Side Rendering) 39 | 40 | Import the `withStorage` function and the `getStorage` helper function. Optional configuration can be passed as 3th argument. 41 | 42 | ```ts 43 | import { withStorage, getStorage } from '@larscom/ngrx-signals-storage' 44 | import { withState, signalStore } from '@ngrx/signals' 45 | 46 | export const CounterStore = signalStore( 47 | withState({ 48 | count: 0 49 | }), 50 | // state will be saved to sessionStorage under the key: 'myKey' 51 | withStorage('myKey', getStorage('sessionStorage')) 52 | ) 53 | ``` 54 | 55 | ## Configuration 56 | 57 | ```ts 58 | export interface Config { 59 | /** 60 | * These keys will not get saved to storage 61 | */ 62 | excludeKeys: Array 63 | 64 | /** 65 | * Serializer for the state, by default it uses `JSON.stringify()` 66 | * @param state the last state known before it gets saved to storage 67 | */ 68 | serialize: (state: T) => string 69 | 70 | /** 71 | * Deserializer for the state, by default it uses `JSON.parse()` 72 | * @param state the last state known from the storage location 73 | */ 74 | deserialize: (state: string) => T 75 | 76 | /** 77 | * Save to storage will only occur when this function returns true 78 | * @param state the last state known before it gets saved to storage 79 | */ 80 | saveIf: (state: T) => boolean 81 | 82 | /** 83 | * Function that gets executed on a storage error (get/set) 84 | * @param error the error that occurred 85 | */ 86 | error: (error: any) => void 87 | } 88 | ``` 89 | 90 | ## Save conditionally 91 | 92 | Sometimes you only want to save to storage on a specific condition. 93 | 94 | ```ts 95 | import { withStorage } from '@larscom/ngrx-signals-storage' 96 | import { withState, signalStore } from '@ngrx/signals' 97 | 98 | export const CounterStore = signalStore( 99 | withState({ 100 | count: 0 101 | }), 102 | // save only occurs when count is higher than 0 103 | withStorage('myKey', sessionStorage, { saveIf: ({ count }) => count > 0 }) 104 | ) 105 | ``` 106 | 107 | ## Skip properties 108 | 109 | Sometimes you want to ignore / exclude properties so they do not get saved into storage. On page reload, the initial value will be loaded instead. 110 | 111 | ```ts 112 | import { withStorage } from '@larscom/ngrx-signals-storage' 113 | import { withState, signalStore } from '@ngrx/signals' 114 | 115 | export const CounterStore = signalStore( 116 | withState({ 117 | count: 0, 118 | sum: 0 119 | }), 120 | // sum does not get saved into sessionStorage. 121 | withStorage('myKey', sessionStorage, { excludeKeys: ['sum'] }) 122 | ) 123 | ``` 124 | 125 | ## Errors 126 | 127 | Whenever you get errors this is most likely due to serialization / deserialization of the state. 128 | 129 | Objects like `Map` and `Set` are not serializable so you might need to implement your own serialize / deserialize function. 130 | 131 | ### Serialize / Deserialize 132 | 133 | Lets say you have a `Set` in your store, then you need a custom serialize / deserialize function to convert from `Set` to `Array` (serialize) and from `Array` to `Set` (deserialize) 134 | 135 | ```ts 136 | export const MyStore = signalStore( 137 | withState({ 138 | mySet: new Set([1, 1, 3, 3]) 139 | }), 140 | withStorage('myKey', sessionStorage, { 141 | serialize: (state) => JSON.stringify({ ...state, mySet: Array.from(state.mySet) }), 142 | deserialize: (stateString) => { 143 | const state = JSON.parse(stateString) 144 | return { 145 | ...state, 146 | mySet: new Set(state.mySet) 147 | } 148 | } 149 | }) 150 | ) 151 | ``` 152 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "ngrx-signals-storage-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-signals-storage-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": ["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-signals-storage-app:build:production" 59 | }, 60 | "development": { 61 | "buildTarget": "ngrx-signals-storage-app:build:development" 62 | } 63 | }, 64 | "defaultConfiguration": "development" 65 | }, 66 | "extract-i18n": { 67 | "builder": "@angular/build:extract-i18n", 68 | "options": { 69 | "buildTarget": "ngrx-signals-storage-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-signals-storage": { 84 | "projectType": "library", 85 | "root": "projects/ngrx-signals-storage", 86 | "sourceRoot": "projects/ngrx-signals-storage/src", 87 | "prefix": "lib", 88 | "architect": { 89 | "build": { 90 | "builder": "@angular/build:ng-packagr", 91 | "options": { 92 | "project": "projects/ngrx-signals-storage/ng-package.json" 93 | }, 94 | "configurations": { 95 | "production": { 96 | "tsConfig": "projects/ngrx-signals-storage/tsconfig.lib.prod.json" 97 | }, 98 | "development": { 99 | "tsConfig": "projects/ngrx-signals-storage/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-signals-storage", 3 | "version": "3.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "test": "ng test ngrx-signals-storage", 8 | "test:watch": "ng test ngrx-signals-storage --watch", 9 | "build": "ng build ngrx-signals-storage" 10 | }, 11 | "private": true, 12 | "dependencies": { 13 | "@angular/animations": "20.0.3", 14 | "@angular/common": "20.0.3", 15 | "@angular/compiler": "20.0.3", 16 | "@angular/core": "20.0.3", 17 | "@angular/forms": "20.0.3", 18 | "@angular/platform-browser": "20.0.3", 19 | "@angular/platform-browser-dynamic": "20.0.3", 20 | "@angular/router": "20.0.3", 21 | "@ngrx/signals": "19.2.1", 22 | "rxjs": "7.8.2", 23 | "tslib": "2.8.1", 24 | "zone.js": "0.15.1" 25 | }, 26 | "devDependencies": { 27 | "@angular-builders/jest": "19.0.1", 28 | "@angular/build": "^20.0.1", 29 | "@angular/cli": "20.0.2", 30 | "@angular/compiler-cli": "20.0.3", 31 | "@types/jest": "29.5.14", 32 | "jest": "30.0.0", 33 | "jest-preset-angular": "14.6.0", 34 | "ng-packagr": "20.0.0", 35 | "typescript": "5.8.3" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /projects/ngrx-signals-storage/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'jest-preset-angular', 3 | transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$|@stomp/rx-stomp)'], 4 | setupFiles: ['./projects/ngrx-signals-storage/setup-jest.ts'] 5 | } 6 | -------------------------------------------------------------------------------- /projects/ngrx-signals-storage/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "../../dist/ngrx-signals-storage", 4 | "lib": { 5 | "entryFile": "src/public-api.ts" 6 | } 7 | } -------------------------------------------------------------------------------- /projects/ngrx-signals-storage/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@larscom/ngrx-signals-storage", 3 | "version": "0.0.0", 4 | "description": "Save signal state (@ngrx/signals) to localstorage/sessionstorage and restore the state on page load (with SSR support).", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/larscom/ngrx-signals-storage.git" 8 | }, 9 | "publishConfig": { 10 | "access": "public" 11 | }, 12 | "keywords": [ 13 | "angular", 14 | "ngrx", 15 | "redux", 16 | "store", 17 | "state", 18 | "signals", 19 | "storage", 20 | "localstorage", 21 | "sessionstorage", 22 | "reactive" 23 | ], 24 | "author": "Lars Kniep", 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/larscom/ngrx-signals-storage/issues" 28 | }, 29 | "homepage": "https://github.com/larscom/ngrx-signals-storage#readme", 30 | "peerDependencies": { 31 | "@angular/core": ">=17.0.0", 32 | "@ngrx/signals": ">=18.0.0" 33 | }, 34 | "dependencies": { 35 | "tslib": "^2.3.0" 36 | }, 37 | "sideEffects": false 38 | } 39 | -------------------------------------------------------------------------------- /projects/ngrx-signals-storage/setup-jest.ts: -------------------------------------------------------------------------------- 1 | Object.assign(global, { structuredClone: (val: any) => JSON.parse(JSON.stringify(val)) }) 2 | -------------------------------------------------------------------------------- /projects/ngrx-signals-storage/src/lib/config.ts: -------------------------------------------------------------------------------- 1 | export interface Config { 2 | /** 3 | * These keys will not get saved to storage 4 | */ 5 | excludeKeys: Array 6 | 7 | /** 8 | * Serializer for the state, by default it uses `JSON.stringify()` 9 | * @param state the last state known before it gets saved to storage 10 | */ 11 | serialize: (state: T) => string 12 | 13 | /** 14 | * Deserializer for the state, by default it uses `JSON.parse()` 15 | * @param state the last state known from the storage location 16 | */ 17 | deserialize: (state: string) => T 18 | 19 | /** 20 | * Save to storage will only occur when this function returns true 21 | * @param state the last state known before it gets saved to storage 22 | */ 23 | saveIf: (state: T) => boolean 24 | 25 | /** 26 | * Function that gets executed on a storage error (get/set) 27 | * @param error the error that occurred 28 | */ 29 | error: (error: any) => void 30 | } 31 | 32 | export const defaultConfig: Config = { 33 | excludeKeys: [], 34 | 35 | serialize: (state: any) => JSON.stringify(state), 36 | 37 | deserialize: (state: string) => JSON.parse(state), 38 | 39 | saveIf: (state: any) => true, 40 | 41 | error: (error: any) => console.error(error) 42 | } 43 | -------------------------------------------------------------------------------- /projects/ngrx-signals-storage/src/lib/ssr.spec.ts: -------------------------------------------------------------------------------- 1 | import { getStorage, NoopStorage } from './ssr' 2 | 3 | describe('getStorage', () => { 4 | const { window } = global 5 | 6 | beforeEach(() => { 7 | // @ts-ignore 8 | delete global.window 9 | }) 10 | 11 | afterEach(() => { 12 | global.window = window 13 | }) 14 | 15 | it('should return window.localStorage if running in the browser and StorageType is LocalStorage', () => { 16 | global.window = { localStorage: jest.fn(), sessionStorage: jest.fn() } as any 17 | 18 | const storage = getStorage('localStorage') 19 | expect(storage).toBe(global.window.localStorage) 20 | }) 21 | 22 | it('should return window.sessionStorage if running in the browser and StorageType is SessionStorage', () => { 23 | global.window = { localStorage: jest.fn(), sessionStorage: jest.fn() } as any 24 | 25 | const storage = getStorage('sessionStorage') 26 | expect(storage).toBe(global.window.sessionStorage) 27 | }) 28 | 29 | it('should return NoopStorage if not running in the browser and StorageType is LocalStorage', () => { 30 | global.window = undefined as any 31 | 32 | const storage = getStorage('localStorage') 33 | expect(storage).toBeInstanceOf(NoopStorage) 34 | }) 35 | 36 | it('should return NoopStorage if not running in the browser and StorageType is SessionStorage', () => { 37 | global.window = undefined as any 38 | 39 | const storage = getStorage('sessionStorage') 40 | expect(storage).toBeInstanceOf(NoopStorage) 41 | }) 42 | 43 | it('should return NoopStorage for an unknown StorageType', () => { 44 | global.window = undefined as any 45 | 46 | const storage = getStorage('' as any) 47 | expect(storage).toBeInstanceOf(NoopStorage) 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /projects/ngrx-signals-storage/src/lib/ssr.ts: -------------------------------------------------------------------------------- 1 | export class NoopStorage implements Storage { 2 | length = -1 3 | clear = () => {} 4 | getItem = (_key: string) => null 5 | key = (_index: number) => null 6 | removeItem = (_key: string) => {} 7 | setItem = (_key: string, _value: string) => {} 8 | } 9 | 10 | function isBrowser() { 11 | return typeof window !== 'undefined' 12 | } 13 | 14 | /** 15 | * Helper function for SSR (Server Side Rendered) apps to get a storage location. 16 | * 17 | * @example 18 | * export const CounterStore = signalStore( 19 | * withState({ 20 | * count: 0 21 | * }), 22 | * withStorage('myKey', getStorage('sessionStorage')) 23 | * ) 24 | * 25 | * Check out github for more information. 26 | * @see https://github.com/larscom/ngrx-signals-storage 27 | */ 28 | export function getStorage(storageType: 'localStorage' | 'sessionStorage'): Storage { 29 | switch (storageType) { 30 | case 'localStorage': { 31 | return isBrowser() ? window.localStorage : new NoopStorage() 32 | } 33 | case 'sessionStorage': { 34 | return isBrowser() ? window.sessionStorage : new NoopStorage() 35 | } 36 | default: { 37 | return new NoopStorage() 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /projects/ngrx-signals-storage/src/lib/with-storage.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing' 2 | import { signalStore, withState } from '@ngrx/signals' 3 | import { withStorage } from './with-storage' 4 | 5 | const storageKey = 'key' 6 | 7 | describe('withStorage', () => { 8 | it('should save to storage', () => { 9 | const storage = new TestStorage() 10 | const TestStore = signalStore( 11 | withState({ 12 | count: 0, 13 | todos: [ 14 | { 15 | name: 'todo 1', 16 | done: false, 17 | date: new Date('2023-01-01T01:00:00') 18 | }, 19 | { 20 | name: 'todo 2', 21 | done: true, 22 | date: new Date('2023-01-01T01:00:00') 23 | } 24 | ] 25 | }), 26 | withStorage(storageKey, storage) 27 | ) 28 | 29 | TestBed.configureTestingModule({ 30 | providers: [TestStore] 31 | }) 32 | 33 | const store = TestBed.inject(TestStore) 34 | expect(store.count()).toBe(0) 35 | expect(store.todos()).toHaveLength(2) 36 | 37 | // trigger effect() 38 | TestBed.flushEffects() 39 | 40 | expect(storage.length).toEqual(1) 41 | expect(JSON.parse(storage.getItem(storageKey)!)).toEqual({ 42 | count: 0, 43 | todos: [ 44 | { 45 | name: 'todo 1', 46 | done: false, 47 | date: '2023-01-01T00:00:00.000Z' 48 | }, 49 | { 50 | name: 'todo 2', 51 | done: true, 52 | date: '2023-01-01T00:00:00.000Z' 53 | } 54 | ] 55 | }) 56 | }) 57 | 58 | it('should rehydrate from storage', () => { 59 | const storage = new TestStorage() 60 | storage.setItem( 61 | storageKey, 62 | JSON.stringify({ 63 | count: 100, 64 | todos: [ 65 | { 66 | name: 'todo 1', 67 | done: false, 68 | date: new Date('2023-01-01T01:00:00') 69 | }, 70 | { 71 | name: 'todo 2', 72 | done: true, 73 | date: new Date('2023-01-01T01:00:00') 74 | } 75 | ] 76 | }) 77 | ) 78 | expect(storage.length).toEqual(1) 79 | 80 | const TestStore = signalStore( 81 | withState({ 82 | count: 0, 83 | todos: [] 84 | }), 85 | withStorage(storageKey, storage) 86 | ) 87 | 88 | TestBed.configureTestingModule({ 89 | providers: [TestStore] 90 | }) 91 | 92 | const store = TestBed.inject(TestStore) 93 | expect(store.count()).toBe(100) 94 | expect(store.todos()).toHaveLength(2) 95 | 96 | // trigger effect() 97 | TestBed.flushEffects() 98 | 99 | expect(storage.length).toEqual(1) 100 | expect(JSON.parse(storage.getItem(storageKey)!)).toEqual({ 101 | count: 100, 102 | todos: [ 103 | { 104 | name: 'todo 1', 105 | done: false, 106 | date: '2023-01-01T00:00:00.000Z' 107 | }, 108 | { 109 | name: 'todo 2', 110 | done: true, 111 | date: '2023-01-01T00:00:00.000Z' 112 | } 113 | ] 114 | }) 115 | }) 116 | 117 | it('should ignore properties from storage if not exist on state', () => { 118 | const storage = new TestStorage() 119 | storage.setItem(storageKey, JSON.stringify({ count: 100, a: true, b: 0 })) 120 | 121 | const TestStore = signalStore( 122 | withState({ 123 | count: 0 124 | }), 125 | withStorage(storageKey, storage) 126 | ) 127 | 128 | TestBed.configureTestingModule({ 129 | providers: [TestStore] 130 | }) 131 | 132 | const store = TestBed.inject(TestStore) 133 | expect(store.count()).toBe(100) 134 | 135 | // trigger effect() 136 | TestBed.flushEffects() 137 | 138 | expect(storage.length).toEqual(1) 139 | expect(JSON.parse(storage.getItem(storageKey)!)).toEqual({ count: 100 }) 140 | }) 141 | 142 | it('should not save to storage when saveIf returns false', () => { 143 | const storage = new TestStorage() 144 | 145 | const TestStore = signalStore( 146 | withState({ 147 | count: 0 148 | }), 149 | withStorage(storageKey, storage, { saveIf: ({ count }) => count > 0 }) 150 | ) 151 | 152 | TestBed.configureTestingModule({ 153 | providers: [TestStore] 154 | }) 155 | 156 | const store = TestBed.inject(TestStore) 157 | expect(store.count()).toBe(0) 158 | 159 | // trigger effect() 160 | TestBed.flushEffects() 161 | 162 | expect(storage.length).toEqual(0) 163 | }) 164 | 165 | it('should call provided serialize func', () => { 166 | const storage = new TestStorage() 167 | 168 | const serialize = jest.fn() 169 | const TestStore = signalStore( 170 | withState({ 171 | count: 0 172 | }), 173 | withStorage(storageKey, storage, { serialize }) 174 | ) 175 | 176 | TestBed.configureTestingModule({ 177 | providers: [TestStore] 178 | }) 179 | 180 | const store = TestBed.inject(TestStore) 181 | expect(store.count()).toBe(0) 182 | 183 | // trigger effect() 184 | TestBed.flushEffects() 185 | 186 | expect(serialize).toHaveBeenCalledWith({ count: 0 }) 187 | }) 188 | 189 | it('should call provided deserialize func', () => { 190 | const storage = new TestStorage() 191 | storage.setItem(storageKey, JSON.stringify({ count: 100 })) 192 | 193 | const deserialize = jest.fn() 194 | const TestStore = signalStore( 195 | withState({ 196 | count: 0 197 | }), 198 | withStorage(storageKey, storage, { deserialize }) 199 | ) 200 | 201 | TestBed.configureTestingModule({ 202 | providers: [TestStore] 203 | }) 204 | 205 | expect(deserialize).toHaveBeenCalledWith(JSON.stringify({ count: 100 })) 206 | }) 207 | 208 | it('should call provided error func on getItem', () => { 209 | const storage = new TestStorage() 210 | 211 | jest.spyOn(storage, 'getItem').mockImplementation(() => { 212 | throw Error('storage error') 213 | }) 214 | 215 | const error = jest.fn() 216 | const TestStore = signalStore( 217 | withState({ 218 | count: 0 219 | }), 220 | withStorage(storageKey, storage, { error }) 221 | ) 222 | 223 | TestBed.configureTestingModule({ 224 | providers: [TestStore] 225 | }) 226 | 227 | expect(error).toHaveBeenCalledWith(Error('storage error')) 228 | }) 229 | 230 | it('should call provided error func on setItem', () => { 231 | const storage = new TestStorage() 232 | 233 | jest.spyOn(storage, 'setItem').mockImplementation(() => { 234 | throw Error('storage error') 235 | }) 236 | 237 | const error = jest.fn() 238 | const TestStore = signalStore( 239 | withState({ 240 | count: 0 241 | }), 242 | withStorage(storageKey, storage, { error }) 243 | ) 244 | 245 | TestBed.configureTestingModule({ 246 | providers: [TestStore] 247 | }) 248 | 249 | const store = TestBed.inject(TestStore) 250 | expect(store.count()).toBe(0) 251 | 252 | // trigger effect() 253 | TestBed.flushEffects() 254 | 255 | expect(error).toHaveBeenCalledWith(Error('storage error')) 256 | }) 257 | 258 | it('should exclude keys from state when saving to storage', () => { 259 | const storage = new TestStorage() 260 | 261 | const TestStore = signalStore( 262 | withState({ 263 | count: 100, 264 | excludeMe: 1, 265 | skipMe: 'test' 266 | }), 267 | withStorage(storageKey, storage, { excludeKeys: ['excludeMe', 'skipMe'] }) 268 | ) 269 | 270 | TestBed.configureTestingModule({ 271 | providers: [TestStore] 272 | }) 273 | 274 | const store = TestBed.inject(TestStore) 275 | expect(store.count()).toBe(100) 276 | expect(store.excludeMe()).toBe(1) 277 | expect(store.skipMe()).toBe('test') 278 | 279 | // trigger effect() 280 | TestBed.flushEffects() 281 | 282 | expect(storage.length).toEqual(1) 283 | expect(JSON.parse(storage.getItem(storageKey)!)).toEqual({ count: 100 }) 284 | }) 285 | }) 286 | 287 | class TestStorage implements Storage { 288 | data = new Map() 289 | 290 | get length(): number { 291 | return this.data.size 292 | } 293 | 294 | clear(): void { 295 | this.data.clear() 296 | } 297 | 298 | getItem(key: string): string | null { 299 | return this.data.get(key) 300 | } 301 | 302 | key(index: number): string { 303 | return Array.from(this.data.keys())[index] 304 | } 305 | 306 | removeItem(key: string): void { 307 | this.data.delete(key) 308 | } 309 | 310 | setItem(key: string, value: string): void { 311 | this.data.set(key, value) 312 | } 313 | } 314 | -------------------------------------------------------------------------------- /projects/ngrx-signals-storage/src/lib/with-storage.ts: -------------------------------------------------------------------------------- 1 | import { effect } from '@angular/core' 2 | import { EmptyFeatureResult, SignalStoreFeature, SignalStoreFeatureResult, getState, patchState } from '@ngrx/signals' 3 | import { Config, defaultConfig } from './config' 4 | 5 | /** 6 | * The `withStorage` function that lets you save the state to localstorage/sessionstorage 7 | * and rehydrate the state upon page load. 8 | * 9 | * @param key the key under which the state should be saved into `Storage` 10 | * @param storage an implementation of the `Storage` interface, like: `sessionStorage` or `localStorage` 11 | * 12 | * @example 13 | * // for apps *without* SSR (Server Side Rendering) 14 | * export const CounterStore = signalStore( 15 | * withState({ 16 | * count: 0 17 | * }), 18 | * withStorage('myKey', sessionStorage) 19 | * ) 20 | * 21 | * @example 22 | * // for apps *with* SSR (Server Side Rendering) 23 | * export const CounterStore = signalStore( 24 | * withState({ 25 | * count: 0 26 | * }), 27 | * withStorage('myKey', getStorage('sessionStorage')) 28 | * ) 29 | * 30 | * Check out github for more information. 31 | * @see https://github.com/larscom/ngrx-signals-storage 32 | */ 33 | export function withStorage( 34 | key: string, 35 | storage: Storage, 36 | config?: Partial> 37 | ): SignalStoreFeature { 38 | const cfg = { ...defaultConfig, ...config } 39 | 40 | const item = getFromStorage(key, storage, cfg) 41 | const storageState: State['state'] | null = item ? cfg.deserialize(item) : null 42 | 43 | let hydrated = false 44 | 45 | return (store) => { 46 | if (storageState != null && !hydrated) { 47 | const stateSignals = store['stateSignals'] 48 | const stateSignalKeys = Object.keys(stateSignals) 49 | const state = stateSignalKeys.reduce((state, key) => { 50 | const value = storageState[key as keyof State['state']] 51 | return value 52 | ? { 53 | ...state, 54 | [key]: value 55 | } 56 | : state 57 | }, getState(store)) 58 | 59 | patchState(store, state) 60 | hydrated = true 61 | } 62 | 63 | effect(() => { 64 | const state = structuredClone(getState(store)) 65 | try { 66 | if (cfg.saveIf(state)) { 67 | cfg.excludeKeys.forEach((key) => { 68 | delete state[key as keyof State['state']] 69 | }) 70 | storage.setItem(key, cfg.serialize(state)) 71 | } 72 | } catch (e) { 73 | cfg.error(e) 74 | } 75 | }) 76 | 77 | return store 78 | } 79 | } 80 | 81 | function getFromStorage( 82 | key: string, 83 | storage: Storage, 84 | cfg: Config 85 | ): string | null { 86 | try { 87 | return storage.getItem(key) 88 | } catch (e) { 89 | cfg.error(e) 90 | return null 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /projects/ngrx-signals-storage/src/public-api.ts: -------------------------------------------------------------------------------- 1 | export { type Config } from './lib/config' 2 | export { getStorage } from './lib/ssr' 3 | export { withStorage } from './lib/with-storage' 4 | -------------------------------------------------------------------------------- /projects/ngrx-signals-storage/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "../../tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "../../out-tsc/lib", 6 | "declaration": true, 7 | "declarationMap": true, 8 | "inlineSources": true, 9 | "types": [] 10 | }, 11 | "exclude": [ 12 | "**/*.spec.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /projects/ngrx-signals-storage/tsconfig.lib.prod.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.lib.json", 4 | "compilerOptions": { 5 | "declarationMap": false 6 | }, 7 | "angularCompilerOptions": { 8 | "compilationMode": "partial" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /projects/ngrx-signals-storage/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "../../tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "../../out-tsc/spec", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": ["**/*.spec.ts", "**/*.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /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.component.html: -------------------------------------------------------------------------------- 1 |

Counter

2 |
{{ store.count() }}
3 | 4 |

Date

5 |
{{ store.date() }}
6 | 7 |

Unique numbers

8 |
{{ uniqueNumbers }}
9 | -------------------------------------------------------------------------------- /src/app/app.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/larscom/ngrx-signals-storage/8e3d8f488c27ecd6cd33946ce57662a2139b8e05/src/app/app.component.scss -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common' 2 | import { Component, inject } from '@angular/core' 3 | import { getStorage, withStorage } from '@larscom/ngrx-signals-storage' 4 | 5 | import { patchState, signalStore, withMethods, withState } from '@ngrx/signals' 6 | 7 | export const CounterStore = signalStore( 8 | withState({ 9 | count: 100, 10 | test: 5, 11 | date: new Date(), 12 | unique: new Set([1, 1, 3, 3]) 13 | }), 14 | withStorage('state', getStorage('sessionStorage'), { 15 | excludeKeys: ['test'], 16 | serialize: (state) => JSON.stringify({ ...state, unique: Array.from(state.unique) }), 17 | deserialize: (stateString) => { 18 | const state = JSON.parse(stateString) 19 | return { 20 | ...state, 21 | unique: new Set(state.unique) 22 | } 23 | } 24 | }), 25 | withMethods(({ count, ...store }) => ({ 26 | setDate(date: Date) { 27 | patchState(store, { date }) 28 | }, 29 | increment(by: number) { 30 | patchState(store, { count: count() + by }) 31 | } 32 | })) 33 | ) 34 | 35 | @Component({ 36 | selector: 'app-root', 37 | standalone: true, 38 | imports: [CommonModule], 39 | providers: [CounterStore], 40 | templateUrl: './app.component.html', 41 | styleUrl: './app.component.scss' 42 | }) 43 | export class AppComponent { 44 | store = inject(CounterStore) 45 | 46 | constructor() { 47 | setTimeout(() => this.store.increment(100), 3000) 48 | } 49 | 50 | get uniqueNumbers() { 51 | return Array.from(this.store.unique()) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/app/app.config.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationConfig } from '@angular/core'; 2 | import { provideRouter } from '@angular/router'; 3 | 4 | import { routes } from './app.routes'; 5 | 6 | export const appConfig: ApplicationConfig = { 7 | providers: [provideRouter(routes)] 8 | }; 9 | -------------------------------------------------------------------------------- /src/app/app.routes.ts: -------------------------------------------------------------------------------- 1 | import { Routes } from '@angular/router'; 2 | 3 | export const routes: Routes = []; 4 | -------------------------------------------------------------------------------- /src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/larscom/ngrx-signals-storage/8e3d8f488c27ecd6cd33946ce57662a2139b8e05/src/assets/.gitkeep -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/larscom/ngrx-signals-storage/8e3d8f488c27ecd6cd33946ce57662a2139b8e05/src/favicon.ico -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | @larscom/ngrx-signals-storage 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { bootstrapApplication } from '@angular/platform-browser'; 2 | import { appConfig } from './app/app.config'; 3 | import { AppComponent } from './app/app.component'; 4 | 5 | bootstrapApplication(AppComponent, appConfig) 6 | .catch((err) => console.error(err)); 7 | -------------------------------------------------------------------------------- /src/styles.scss: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/app", 6 | "types": [] 7 | }, 8 | "files": [ 9 | "src/main.ts" 10 | ], 11 | "include": [ 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "baseUrl": "./", 6 | "outDir": "./dist/out-tsc", 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": true, 9 | "noImplicitOverride": true, 10 | "noPropertyAccessFromIndexSignature": true, 11 | "noImplicitReturns": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "skipLibCheck": true, 14 | "paths": { 15 | "@larscom/ngrx-signals-storage": [ 16 | "projects/ngrx-signals-storage/src/public-api" 17 | ] 18 | }, 19 | "esModuleInterop": true, 20 | "sourceMap": true, 21 | "declaration": false, 22 | "experimentalDecorators": true, 23 | "moduleResolution": "bundler", 24 | "importHelpers": true, 25 | "target": "ES2022", 26 | "module": "ES2022", 27 | "useDefineForClassFields": false, 28 | "lib": [ 29 | "ES2022", 30 | "dom" 31 | ] 32 | }, 33 | "angularCompilerOptions": { 34 | "enableI18nLegacyMessageIdFormat": false, 35 | "strictInjectionParameters": true, 36 | "strictInputAccessModifiers": true, 37 | "strictTemplates": true, 38 | "disableTypeScriptVersionCheck": true 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/spec", 6 | "types": [ 7 | "jest", "node" 8 | ] 9 | }, 10 | "include": [ 11 | "src/**/*.spec.ts", 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | --------------------------------------------------------------------------------