├── .npmignore ├── .npmrc ├── .gitignore ├── demo-screen-shot.png ├── tsconfig.esm2015.json ├── tsconfig.esm5.json ├── setup-fake-timers.ts ├── src ├── stopPersisting.ts ├── isHydrated.ts ├── pausePersisting.ts ├── startPersisting.ts ├── hydrateStore.ts ├── isPersisting.ts ├── PersistStoreMap.ts ├── clearPersistedStore.ts ├── getPersistedStore.ts ├── configurePersistable.ts ├── index.ts ├── makePersistable.ts ├── serializableProperty.ts ├── StorageAdapter.ts ├── types.ts ├── utils.ts └── PersistStore.ts ├── .prettierrc ├── babel.config.js ├── scripts └── make-release-branch.sh ├── .github └── workflows │ └── npm-release.yml ├── tsconfig.json ├── __tests__ ├── configurePersistable.test.ts ├── utils.test.ts ├── StorePersist.test.ts └── StorageAdapter.test.ts ├── package.json └── Readme.md /.npmignore: -------------------------------------------------------------------------------- 1 | __tests__/ 2 | src/ -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | lib/ 3 | .idea 4 | yarn-error.log 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /demo-screen-shot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quarrant/mobx-persist-store/HEAD/demo-screen-shot.png -------------------------------------------------------------------------------- /tsconfig.esm2015.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "ES2015", 5 | "outDir": "./lib/esm2015" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.esm5.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ES5", 5 | "module": "ES2015", 6 | "outDir": "./lib/esm5" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /setup-fake-timers.ts: -------------------------------------------------------------------------------- 1 | import fakeTimers from '@sinonjs/fake-timers'; 2 | 3 | export const clock = fakeTimers.install({ 4 | toFake: Object.keys(fakeTimers.timers) as fakeTimers.FakeMethod[], 5 | }); 6 | -------------------------------------------------------------------------------- /src/stopPersisting.ts: -------------------------------------------------------------------------------- 1 | import { PersistStoreMap } from './PersistStoreMap'; 2 | 3 | export const stopPersisting = (target: T): void => { 4 | PersistStoreMap.get(target)?.stopPersisting(); 5 | }; 6 | -------------------------------------------------------------------------------- /src/isHydrated.ts: -------------------------------------------------------------------------------- 1 | import { PersistStoreMap } from './PersistStoreMap'; 2 | 3 | export const isHydrated = (target: T): boolean => { 4 | return PersistStoreMap.get(target)?.isHydrated ?? false; 5 | }; 6 | -------------------------------------------------------------------------------- /src/pausePersisting.ts: -------------------------------------------------------------------------------- 1 | import { PersistStoreMap } from './PersistStoreMap'; 2 | 3 | export const pausePersisting = (target: T): void => { 4 | PersistStoreMap.get(target)?.pausePersisting(); 5 | }; 6 | -------------------------------------------------------------------------------- /src/startPersisting.ts: -------------------------------------------------------------------------------- 1 | import { PersistStoreMap } from './PersistStoreMap'; 2 | 3 | export const startPersisting = (target: T): void => { 4 | PersistStoreMap.get(target)?.startPersisting(); 5 | }; 6 | -------------------------------------------------------------------------------- /src/hydrateStore.ts: -------------------------------------------------------------------------------- 1 | import { PersistStoreMap } from './PersistStoreMap'; 2 | 3 | export const hydrateStore = async (target: T): Promise => { 4 | await PersistStoreMap.get(target)?.hydrateStore(); 5 | }; 6 | -------------------------------------------------------------------------------- /src/isPersisting.ts: -------------------------------------------------------------------------------- 1 | import { PersistStoreMap } from './PersistStoreMap'; 2 | 3 | export const isPersisting = (target: T): boolean => { 4 | return PersistStoreMap.get(target)?.isPersisting ?? false; 5 | }; 6 | -------------------------------------------------------------------------------- /src/PersistStoreMap.ts: -------------------------------------------------------------------------------- 1 | import { PersistStore } from './PersistStore'; 2 | 3 | /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ 4 | export const PersistStoreMap: Map> = new Map(); 5 | -------------------------------------------------------------------------------- /src/clearPersistedStore.ts: -------------------------------------------------------------------------------- 1 | import { PersistStoreMap } from './PersistStoreMap'; 2 | 3 | export const clearPersistedStore = async (target: T): Promise => { 4 | await PersistStoreMap.get(target)?.clearPersistedStore(); 5 | }; 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "jsxSingleQuote": false, 4 | "printWidth": 120, 5 | "semi": true, 6 | "singleQuote": true, 7 | "singleAttributePerLine": true, 8 | "tabWidth": 2, 9 | "trailingComma": "es5" 10 | } 11 | -------------------------------------------------------------------------------- /src/getPersistedStore.ts: -------------------------------------------------------------------------------- 1 | import { PersistStoreMap } from './PersistStoreMap'; 2 | 3 | export const getPersistedStore = async >(target: T): Promise => { 4 | return PersistStoreMap.get(target)?.getPersistedStore() ?? null; 5 | }; 6 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@babel/preset-typescript', '@babel/preset-env', '@babel/preset-react'], 3 | plugins: [ 4 | 'babel-plugin-transform-typescript-metadata', 5 | ['@babel/plugin-proposal-decorators', { legacy: true }], 6 | ['@babel/plugin-proposal-class-properties'], 7 | ], 8 | }; 9 | -------------------------------------------------------------------------------- /src/configurePersistable.ts: -------------------------------------------------------------------------------- 1 | import { ReactionOptions, StorageOptions } from './types'; 2 | 3 | export let mpsConfig: Readonly = {}; 4 | export let mpsReactionOptions: Readonly = {}; 5 | 6 | export const configurePersistable = (config: StorageOptions, reactionOptions: ReactionOptions = {}): void => { 7 | mpsConfig = config; 8 | mpsReactionOptions = reactionOptions; 9 | }; 10 | -------------------------------------------------------------------------------- /scripts/make-release-branch.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | type="patch" 4 | if [[ "$1" != "" ]]; then 5 | type=$1 6 | fi 7 | 8 | branch="$(git rev-parse --abbrev-ref HEAD)" 9 | if [[ "$branch" != "master" ]]; then 10 | echo 'Checkout to master branch!'; 11 | exit 1; 12 | fi 13 | 14 | version=$(npm version $type --no-git-tag-version) 15 | 16 | git add package*.json 17 | git commit -m "release-$version" 18 | 19 | git tag $version 20 | git push origin --tags -------------------------------------------------------------------------------- /.github/workflows/npm-release.yml: -------------------------------------------------------------------------------- 1 | name: npm-publish 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v1 13 | - uses: actions/setup-node@v1 14 | with: 15 | node-version: 18 16 | registry-url: https://registry.npmjs.org/ 17 | - run: npm ci --no-audit 18 | - run: npm publish --access public 19 | env: 20 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "module": "CommonJS", 5 | "moduleResolution": "node", 6 | "experimentalDecorators": true, 7 | "allowSyntheticDefaultImports": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "declaration": true, 11 | "outDir": "./lib/esm2017", 12 | "strict": true, 13 | "jsx": "react" 14 | }, 15 | "include": ["**/*.ts", "**/*.tsx"], 16 | "exclude": ["**/*.test.ts", "node_modules", "setup-fake-timers.ts"] 17 | } 18 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './clearPersistedStore'; 2 | export * from './configurePersistable'; 3 | export * from './getPersistedStore'; 4 | export * from './hydrateStore'; 5 | export * from './isHydrated'; 6 | export * from './isPersisting'; 7 | export * from './makePersistable'; 8 | export * from './pausePersisting'; 9 | export * from './PersistStore'; 10 | export * from './PersistStoreMap'; 11 | export * from './startPersisting'; 12 | export * from './stopPersisting'; 13 | export * from './StorageAdapter'; 14 | export * from './types'; 15 | -------------------------------------------------------------------------------- /__tests__/configurePersistable.test.ts: -------------------------------------------------------------------------------- 1 | import ms from 'milliseconds'; 2 | import { mpsConfig, mpsReactionOptions, StorageOptions, configurePersistable, ReactionOptions } from '../src'; 3 | 4 | describe('configurePersistable', () => { 5 | const config: StorageOptions = { 6 | expireIn: ms.days(7), 7 | removeOnExpiration: false, 8 | stringify: false, 9 | storage: localStorage, 10 | }; 11 | const reactionOptions: ReactionOptions = { delay: 200 }; 12 | 13 | test(`should set global config`, () => { 14 | configurePersistable(config, reactionOptions); 15 | 16 | expect(config).toBe(mpsConfig); 17 | expect(reactionOptions).toBe(mpsReactionOptions); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/makePersistable.ts: -------------------------------------------------------------------------------- 1 | import { PersistenceStorageOptions, ReactionOptions } from './types'; 2 | import { PersistStore } from './PersistStore'; 3 | import { PersistStoreMap } from './PersistStoreMap'; 4 | import { duplicatedStoreWarningIf } from './utils'; 5 | 6 | const setMobxPersistStore = (target: T, persistStore: PersistStore) => { 7 | if (process.env.NODE_ENV !== 'production') { 8 | // @ts-ignore Type 'IterableIterator<[any, PersistStore]>' can only be iterated through when using the '--downlevelIteration' flag or with a '--target' of 'es2015' or higher. 9 | for (const [key, store] of PersistStoreMap.entries()) { 10 | if (store.storageName === persistStore.storageName) { 11 | store.stopPersisting() 12 | PersistStoreMap.delete(key) 13 | } 14 | } 15 | } 16 | 17 | PersistStoreMap.set(target, persistStore); 18 | } 19 | 20 | export const makePersistable = async ( 21 | target: T, 22 | storageOptions: PersistenceStorageOptions, 23 | reactionOptions?: ReactionOptions 24 | ): Promise> => { 25 | const mobxPersistStore = new PersistStore(target, storageOptions, reactionOptions); 26 | 27 | const hasPersistedStoreAlready = Array.from(PersistStoreMap.values()) 28 | .map((item) => item.storageName) 29 | .includes(mobxPersistStore.storageName); 30 | 31 | duplicatedStoreWarningIf(hasPersistedStoreAlready, mobxPersistStore.storageName); 32 | setMobxPersistStore(target, mobxPersistStore); 33 | 34 | return mobxPersistStore.init(); 35 | }; 36 | -------------------------------------------------------------------------------- /src/serializableProperty.ts: -------------------------------------------------------------------------------- 1 | import { mpsConfig } from './configurePersistable'; 2 | import { PersistenceStorageOptions } from './types'; 3 | import { consoleDebug, isObject } from './utils'; 4 | 5 | export type SerializableProperty = { 6 | [X in P]: { 7 | key: X; 8 | serialize: (value: T[X]) => any; 9 | deserialize: (value: any) => T[X]; 10 | }; 11 | }[P]; 12 | 13 | const isSerializableProperty = (obj: any): obj is SerializableProperty => { 14 | const keys: (keyof SerializableProperty)[] = ['key', 'serialize', 'deserialize']; 15 | 16 | if (!isObject(obj)) { 17 | consoleDebug(!!mpsConfig.debugMode, 'passed value is not an object', { obj }); 18 | return false; 19 | } 20 | 21 | return keys.every((key) => { 22 | if (obj.hasOwnProperty(key) && typeof key !== 'undefined') { 23 | return true; 24 | } 25 | 26 | consoleDebug(!!mpsConfig.debugMode, `${String(key)} not found in SerializableProperty`, { key, obj }); 27 | return false; 28 | }); 29 | }; 30 | 31 | export const makeSerializableProperties = ( 32 | properties: PersistenceStorageOptions['properties'] 33 | ): SerializableProperty[] => { 34 | return properties.reduce( 35 | (acc, curr) => { 36 | if (typeof curr === 'string') { 37 | acc.push({ 38 | key: curr, 39 | serialize: (value: V) => value, 40 | deserialize: (value: any) => value, 41 | }); 42 | 43 | return acc; 44 | } 45 | 46 | if (isSerializableProperty(curr)) { 47 | acc.push(curr); 48 | return acc; 49 | } 50 | 51 | return acc; 52 | }, 53 | [] as SerializableProperty[] 54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /src/StorageAdapter.ts: -------------------------------------------------------------------------------- 1 | import { StorageOptions } from './types'; 2 | import { buildExpireTimestamp, consoleDebug, hasTimestampExpired, omitObjectProperties } from './utils'; 3 | 4 | export class StorageAdapter { 5 | public readonly options: StorageOptions; 6 | 7 | constructor(options: StorageOptions) { 8 | this.options = options; 9 | } 10 | 11 | async setItem>(key: string, item: T): Promise { 12 | const { stringify = true, debugMode = false } = this.options; 13 | 14 | const __mps__ = omitObjectProperties( 15 | { 16 | expireInTimestamp: this.options.expireIn ? buildExpireTimestamp(this.options.expireIn) : undefined, 17 | version: this.options.version, 18 | }, 19 | (value) => value === undefined 20 | ); 21 | 22 | const data: T = Object.keys(__mps__).length 23 | ? Object.assign({}, item, { 24 | __mps__, 25 | }) 26 | : item; 27 | 28 | const content = stringify ? JSON.stringify(data) : data; 29 | 30 | consoleDebug(debugMode, `${key} - setItem:`, content); 31 | 32 | await this.options.storage?.setItem(key, content); 33 | } 34 | 35 | async getItem>(key: string): Promise { 36 | const { removeOnExpiration = true, debugMode = false, version } = this.options; 37 | const storageData = await this.options.storage?.getItem(key); 38 | let parsedData: T; 39 | 40 | try { 41 | parsedData = JSON.parse(storageData as string) || {}; 42 | } catch (error) { 43 | parsedData = (storageData as T) || ({} as T); 44 | } 45 | 46 | const hasExpired = hasTimestampExpired(parsedData.__mps__?.expireInTimestamp); 47 | 48 | const mismatchedVersion = version && parsedData.__mps__?.version !== version; 49 | 50 | consoleDebug(debugMode, `${key} - hasExpired`, hasExpired); 51 | 52 | consoleDebug(debugMode, `${key} - mismatchedVersion`, mismatchedVersion); 53 | 54 | if ((hasExpired && removeOnExpiration) || mismatchedVersion) { 55 | await this.removeItem(key); 56 | } 57 | 58 | parsedData = hasExpired || mismatchedVersion ? ({} as T) : parsedData; 59 | 60 | consoleDebug(debugMode, `${key} - (getItem):`, parsedData); 61 | 62 | return parsedData; 63 | } 64 | 65 | async removeItem(key: string): Promise { 66 | const { debugMode = false } = this.options; 67 | 68 | consoleDebug(debugMode, `${key} - (removeItem): storage was removed`); 69 | 70 | await this.options.storage?.removeItem(key); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { IAutorunOptions } from 'mobx'; 2 | import { SerializableProperty } from './serializableProperty'; 3 | 4 | export type ReactionOptions = IAutorunOptions & { fireImmediately?: boolean }; 5 | 6 | export interface PersistenceStorageOptions extends StorageOptions { 7 | name: string; 8 | version?: number; 9 | properties: (P | SerializableProperty)[]; 10 | } 11 | 12 | export interface StorageOptions { 13 | /** 14 | * @property {Boolean} [debugMode] When true console.info when getItem, setItem or removeItem are triggered. 15 | * @default false 16 | */ 17 | debugMode?: boolean; 18 | /** 19 | * @property {Number} [expireIn] A value in milliseconds to determine when the data in storage should not be retrieved by getItem. 20 | * 21 | * Recommend the library https://github.com/henrikjoreteg/milliseconds to set the value 22 | */ 23 | expireIn?: number; 24 | /** 25 | * @property {Boolean} [removeOnExpiration] If {@link StorageOptions#expireIn} has a value and has expired, the data in storage will be removed automatically when getItem is called. The default value is true. 26 | * @default true 27 | */ 28 | removeOnExpiration?: boolean; 29 | /** 30 | * 31 | */ 32 | storage?: StorageController; 33 | /** 34 | * @property {Boolean} [jsonify] When true the data will be JSON.stringify before being passed to setItem. The default value is true. 35 | * @default true 36 | */ 37 | stringify?: boolean; 38 | 39 | /** 40 | * @property {number} [version] When specified, the data will be automatically removed from the storage if the versions don't match. By default, there is no version. 41 | */ 42 | version?: number; 43 | } 44 | 45 | export interface StorageController { 46 | /** 47 | * The function that will retrieved the storage data by a specific identifier. 48 | * 49 | * @function 50 | * @param {String} key 51 | * @return {Promise} 52 | */ 53 | getItem(key: string): T | string | null | Promise; 54 | /** 55 | * The function that will remove data from storage by a specific identifier. 56 | * 57 | * @function 58 | * @param {String} key 59 | * @return {Promise} 60 | */ 61 | removeItem(key: string): void | Promise; 62 | /** 63 | * The function that will save data to the storage by a specific identifier. 64 | * 65 | * @function 66 | * @param {String} key 67 | * @param {String | Object} value 68 | * @return {Promise} 69 | */ 70 | setItem(key: string, value: any): void | Promise; 71 | } 72 | -------------------------------------------------------------------------------- /__tests__/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { buildExpireTimestamp, hasTimestampExpired, isDefined, isObjectWithProperties, isObject } from '../src/utils'; 2 | import ms from 'milliseconds'; 3 | 4 | describe('buildExpireTimestamp', () => { 5 | test(`should build expire timestamp 5 days from now`, async () => { 6 | const fiveDaysInMilliseconds = ms.days(5); 7 | 8 | const actualResult = buildExpireTimestamp(fiveDaysInMilliseconds); 9 | const expectedResult = new Date().getTime() + fiveDaysInMilliseconds; 10 | 11 | expect(actualResult).toBe(expectedResult); 12 | }); 13 | }); 14 | 15 | describe('hasTimestampExpired', () => { 16 | test(`should not expire one milliseconds after timestamp`, async () => { 17 | const oneMillisecondFromNow = buildExpireTimestamp(1); 18 | 19 | const actualResult = hasTimestampExpired(oneMillisecondFromNow); 20 | const expectedResult = false; 21 | 22 | expect(actualResult).toBe(expectedResult); 23 | }); 24 | 25 | test(`should expire if same as timestamp`, async () => { 26 | const now = buildExpireTimestamp(0); 27 | 28 | const actualResult = hasTimestampExpired(now); 29 | const expectedResult = true; 30 | 31 | expect(actualResult).toBe(expectedResult); 32 | }); 33 | 34 | test(`should expire one milliseconds before timestamp`, async () => { 35 | const oneMillisecondBeforeNow = buildExpireTimestamp(-1); 36 | 37 | const actualResult = hasTimestampExpired(oneMillisecondBeforeNow); 38 | const expectedResult = true; 39 | 40 | expect(actualResult).toBe(expectedResult); 41 | }); 42 | }); 43 | 44 | describe('Testing Utils', () => { 45 | describe('isDefined', () => { 46 | test('returns false if value is null or undefined, otherwise true', () => { 47 | expect(isDefined(undefined)).toBe(false); 48 | expect(isDefined(null)).toBe(false); 49 | expect(isDefined(NaN)).toBe(true); 50 | expect(isDefined(0)).toBe(true); 51 | expect(isDefined(false)).toBe(true); 52 | expect(isDefined('')).toBe(true); 53 | }); 54 | }); 55 | 56 | test('Util.isObject', () => { 57 | expect(isObject(undefined)).toBeFalsy(); 58 | expect(isObject(null)).toBeFalsy(); 59 | expect(isObject(1)).toBeFalsy(); 60 | expect(isObject('string')).toBeFalsy(); 61 | expect(isObject([])).toBeFalsy(); 62 | 63 | expect(isObject({})).toBeTruthy(); 64 | expect(isObject({ hey: 'there' })).toBeTruthy(); 65 | }); 66 | 67 | test('isObjectWithProperties', () => { 68 | expect(isObjectWithProperties({})).toBeFalsy(); 69 | expect(isObjectWithProperties({ hey: 'there' })).toBeTruthy(); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mobx-persist-store", 3 | "version": "1.1.8", 4 | "description": "Mobx Persist Store", 5 | "author": "quarrant", 6 | "license": "MIT", 7 | "main": "lib/esm2017/index.js", 8 | "module": "lib/esm5/index.js", 9 | "es2015": "lib/esm2015/index.js", 10 | "files": [ 11 | "lib/" 12 | ], 13 | "keywords": [ 14 | "mobx", 15 | "persist", 16 | "mobx-persist", 17 | "react-native", 18 | "react" 19 | ], 20 | "homepage": "https://github.com/quarrant/mobx-persist-store#readme", 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/quarrant/mobx-persist-store.git" 24 | }, 25 | "bugs": { 26 | "url": "https://github.com/quarrant/mobx-persist-store/issues" 27 | }, 28 | "scripts": { 29 | "---------- Building ----------------------------------------------------": "", 30 | "build": "rm -rf lib && npm run build:esm5 && npm run build:esm2015 && npm run build:esm2017", 31 | "build:esm5": "tsc --project tsconfig.esm5.json", 32 | "build:esm2015": "tsc --project tsconfig.esm2015.json", 33 | "build:esm2017": "tsc --project tsconfig.json", 34 | "prepublishOnly": "npm run build", 35 | "npm:release": "bash scripts/make-release-branch.sh", 36 | "---------- Testing ----------------------------------------------------": "", 37 | "test": "jest", 38 | "---------- Linting ----------------------------------------------------": "", 39 | "ts": "tsc --noEmit", 40 | "---------- helper commands --------------------------------------------": "", 41 | "prettier": "npx prettier --write \"./**/*.{ts,tsx,js,jsx,json,md}\"", 42 | "-----------------------------------------------------------------------": "" 43 | }, 44 | "peerDependencies": { 45 | "mobx": "*" 46 | }, 47 | "devDependencies": { 48 | "@babel/core": "^7.18.2", 49 | "@babel/plugin-proposal-class-properties": "^7.17.12", 50 | "@babel/plugin-proposal-decorators": "^7.18.2", 51 | "@babel/preset-env": "^7.18.2", 52 | "@babel/preset-react": "^7.17.12", 53 | "@babel/preset-typescript": "^7.17.12", 54 | "@babel/runtime": "^7.18.3", 55 | "@babel/types": "^7.18.4", 56 | "@types/jest": "^28.1.1", 57 | "@types/milliseconds": "^0.0.30", 58 | "@types/node": "^17.0.40", 59 | "babel-plugin-transform-typescript-metadata": "^0.3.2", 60 | "jest": "^28.1.0", 61 | "jest-environment-jsdom": "^28.1.0", 62 | "jest-localstorage-mock": "^2.4.21", 63 | "jest-mock-console": "^2.0.0", 64 | "milliseconds": "^1.0.3", 65 | "mobx": "^6.3.0", 66 | "ts-jest": "^28.0.4", 67 | "typescript": "^4.2.4" 68 | }, 69 | "jest": { 70 | "modulePaths": [ 71 | "/src/" 72 | ], 73 | "moduleFileExtensions": [ 74 | "ts", 75 | "tsx", 76 | "js" 77 | ], 78 | "transform": { 79 | "^.+\\.ts?$": "ts-jest" 80 | }, 81 | "testPathIgnorePatterns": [ 82 | "\\.snap$", 83 | "/node_modules/", 84 | "/examples/" 85 | ], 86 | "transformIgnorePatterns": [ 87 | "node_modules/(?!react-native)/" 88 | ], 89 | "resetMocks": false, 90 | "setupFiles": [ 91 | "jest-localstorage-mock" 92 | ] 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { StorageController } from './types'; 2 | 3 | export const buildExpireTimestamp = (milliseconds: number): number => { 4 | return new Date().getTime() + milliseconds; 5 | }; 6 | 7 | export const hasTimestampExpired = (milliseconds: number): boolean => { 8 | const dateTimeNow = new Date().getTime(); 9 | const dateTimeExpiration = new Date(milliseconds).getTime(); 10 | 11 | return dateTimeExpiration <= dateTimeNow; 12 | }; 13 | 14 | export const isDefined = (t: T | null | undefined): t is T => t != null; 15 | 16 | /** 17 | * Check if the data is an object. 18 | */ 19 | export const isObject = (data: any): boolean => { 20 | return Boolean(data) && Array.isArray(data) === false && typeof data === 'object'; 21 | }; 22 | 23 | /** 24 | * Check the data is an object with properties. 25 | */ 26 | export const isObjectWithProperties = (data: any): boolean => { 27 | return isObject(data) && Object.keys(data).length > 0; 28 | }; 29 | 30 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 31 | export const isFunction = (functionToCheck: any): boolean => { 32 | return functionToCheck && typeof functionToCheck === 'function'; 33 | }; 34 | 35 | export const isStorageControllerLike = (value: StorageController | Storage | undefined): value is StorageController => { 36 | // "typeof Storage" fixes issue with React Native 37 | if (typeof Storage !== 'undefined' && value instanceof Storage) { 38 | return true; 39 | } 40 | 41 | return [ 42 | value?.hasOwnProperty('getItem'), 43 | value?.hasOwnProperty('removeItem'), 44 | value?.hasOwnProperty('setItem'), 45 | isFunction(value?.getItem), 46 | isFunction(value?.removeItem), 47 | isFunction(value?.setItem), 48 | ].every(Boolean); 49 | }; 50 | 51 | const isBrowser = typeof window !== 'undefined'; 52 | const isNotProductionBuild = process.env.NODE_ENV !== 'production'; 53 | 54 | export const invalidStorageAdaptorWarningIf = ( 55 | storageAdaptor: StorageController | undefined, 56 | storageName: string 57 | ): void => { 58 | if (isBrowser && isNotProductionBuild && !isStorageControllerLike(storageAdaptor)) { 59 | console.warn( 60 | `mobx-persist-store: ${storageName} does not have a valid storage adaptor.\n\n* Make sure the storage controller has 'getItem', 'setItem' and 'removeItem' methods."` 61 | ); 62 | } 63 | }; 64 | 65 | export const duplicatedStoreWarningIf = (hasPersistedStoreAlready: boolean, storageName: string): void => { 66 | if (isBrowser && isNotProductionBuild && hasPersistedStoreAlready) { 67 | console.warn( 68 | `mobx-persist-store: 'makePersistable' was called with the same storage name "${storageName}".\n\n * Make sure you call "stopPersisting" before recreating "${storageName}" to avoid memory leaks. \n * Or double check you did not have two stores with the same name.` 69 | ); 70 | } 71 | }; 72 | 73 | export const computedPersistWarningIf =

(isComputedProperty: boolean, propertyName: P): void => { 74 | if (isBrowser && isNotProductionBuild && isComputedProperty) { 75 | console.warn(`mobx-persist-store: The property '${propertyName}' is computed and will not persist.`); 76 | } 77 | }; 78 | 79 | export const actionPersistWarningIf = (isComputedProperty: boolean, propertyName: string): void => { 80 | if (isBrowser && isNotProductionBuild && isComputedProperty) { 81 | console.warn(`mobx-persist-store: The property '${propertyName}' is an action and will not persist.`); 82 | } 83 | }; 84 | 85 | export const consoleDebug = (isDebugMode: boolean, message: string, content: any = ''): void => { 86 | if (isDebugMode && isBrowser && isNotProductionBuild) { 87 | console.info( 88 | `%c mobx-persist-store: (Debug Mode) ${message} `, 89 | 'background: #4B8CC5; color: black; display: block;', 90 | content 91 | ); 92 | } 93 | }; 94 | 95 | export const isArrayForMap = (value: unknown): value is [any, any][] => { 96 | if (Array.isArray(value)) { 97 | return value.every((v) => Array.isArray(v)); 98 | } 99 | 100 | return false; 101 | }; 102 | 103 | export const isArrayForSet = (value: unknown): value is any[] => { 104 | return Array.isArray(value); 105 | }; 106 | 107 | export const omitObjectProperties = (obj: Record, testFn: (value: V) => boolean) => { 108 | Object.keys(obj).forEach((key) => testFn(obj[key]) && delete obj[key]); 109 | return obj; 110 | }; 111 | -------------------------------------------------------------------------------- /__tests__/StorePersist.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | import ms from 'milliseconds'; 5 | import { makeObservable, observable, runInAction } from 'mobx'; 6 | import mockConsole from 'jest-mock-console'; 7 | 8 | import { ReactionOptions, StorageOptions, PersistStore, configurePersistable } from '../src'; 9 | 10 | class MyStore { 11 | list: string[] = []; 12 | setData: Set = new Set(); 13 | 14 | constructor() { 15 | makeObservable(this, { 16 | list: observable, 17 | setData: observable, 18 | }); 19 | } 20 | } 21 | 22 | describe('StorePersist', () => { 23 | const myStore = new MyStore(); 24 | const persistenceStorageOptions: StorageOptions = { 25 | expireIn: ms.days(7), 26 | removeOnExpiration: false, 27 | stringify: false, 28 | debugMode: false, 29 | storage: localStorage, 30 | }; 31 | const reactionOptions: ReactionOptions = { 32 | delay: 200, 33 | fireImmediately: false, 34 | }; 35 | let restoreConsole: ReturnType; 36 | 37 | beforeEach(() => { 38 | restoreConsole = mockConsole(); 39 | 40 | configurePersistable({}); 41 | }); 42 | 43 | afterEach(() => { 44 | restoreConsole(); 45 | }); 46 | 47 | describe('storageAdapter', () => { 48 | test(`should have default values`, () => { 49 | const storePersist = new PersistStore(myStore, { name: 'myStore', properties: ['list'] }); 50 | 51 | expect(storePersist['storageAdapter']).toEqual({ 52 | options: { 53 | expireIn: undefined, 54 | removeOnExpiration: true, 55 | storage: undefined, 56 | stringify: true, 57 | debugMode: false, 58 | }, 59 | }); 60 | expect(storePersist['reactionOptions']).toEqual({ delay: undefined, fireImmediately: true }); 61 | expect(console.warn).toHaveBeenCalledWith( 62 | `mobx-persist-store: myStore does not have a valid storage adaptor.\n\n* Make sure the storage controller has 'getItem', 'setItem' and 'removeItem' methods."` 63 | ); 64 | }); 65 | 66 | test(`should be all set`, () => { 67 | const storePersist = new PersistStore( 68 | myStore, 69 | { 70 | name: 'myStoreSet', 71 | properties: ['list'], 72 | ...persistenceStorageOptions, 73 | }, 74 | reactionOptions 75 | ); 76 | 77 | expect(storePersist['storageAdapter']).toEqual({ options: persistenceStorageOptions }); 78 | expect(storePersist['reactionOptions']).toEqual(reactionOptions); 79 | }); 80 | 81 | test(`should be all set from configurePersistable`, () => { 82 | configurePersistable( 83 | { 84 | ...persistenceStorageOptions, 85 | }, 86 | { 87 | ...reactionOptions, 88 | } 89 | ); 90 | const storePersist = new PersistStore(myStore, { name: 'myStoreConfigurePersistable', properties: ['list'] }); 91 | 92 | expect(storePersist['storageAdapter']).toEqual({ options: persistenceStorageOptions }); 93 | expect(storePersist['reactionOptions']).toEqual(reactionOptions); 94 | }); 95 | 96 | test(`should override options from configurePersistable`, () => { 97 | configurePersistable( 98 | { 99 | ...persistenceStorageOptions, 100 | }, 101 | { 102 | ...reactionOptions, 103 | } 104 | ); 105 | 106 | const storage = { 107 | setItem: (key: string, value: string) => {}, 108 | getItem: (key: string) => '', 109 | removeItem: (key: string) => {}, 110 | }; 111 | const storePersist = new PersistStore( 112 | myStore, 113 | { 114 | name: 'myStoreOverride', 115 | properties: ['list'], 116 | expireIn: ms.hours(7), 117 | removeOnExpiration: true, 118 | stringify: true, 119 | debugMode: true, 120 | storage: storage, 121 | }, 122 | { delay: 300, fireImmediately: true } 123 | ); 124 | 125 | expect(storePersist['storageAdapter']).toEqual({ 126 | options: { 127 | expireIn: ms.hours(7), 128 | removeOnExpiration: true, 129 | stringify: true, 130 | debugMode: true, 131 | storage: storage, 132 | }, 133 | }); 134 | expect(storePersist['reactionOptions']).toEqual({ delay: 300, fireImmediately: true }); 135 | expect(storePersist['storageAdapter']?.options.storage).toBe(storage); 136 | }); 137 | 138 | test('should work serialize/deserialize', async () => { 139 | const storePersist = new PersistStore(myStore, { 140 | name: 'myStoreSet', 141 | properties: [ 142 | { 143 | key: 'list', 144 | // @ts-ignore 145 | serialize: (value) => { 146 | return value.join(','); 147 | }, 148 | // @ts-ignore 149 | deserialize: (value) => { 150 | return value.split(','); 151 | }, 152 | }, 153 | { 154 | key: 'setData', 155 | // @ts-ignore 156 | serialize: (value) => { 157 | return Array.from(value).join('|'); 158 | }, 159 | // @ts-ignore 160 | deserialize: (value) => { 161 | return new Set(value.split('|').filter(Boolean)); 162 | }, 163 | }, 164 | ], 165 | ...persistenceStorageOptions, 166 | stringify: true, 167 | }); 168 | 169 | const spyOnSerialize = jest.spyOn(storePersist['properties'][0], 'serialize'); 170 | const spyOnDeserialize = jest.spyOn(storePersist['properties'][0], 'deserialize'); 171 | 172 | await storePersist.init(); 173 | 174 | expect(storePersist.isHydrated).toEqual(true); 175 | 176 | expect(spyOnSerialize).toBeCalledTimes(1); 177 | expect(spyOnDeserialize).toBeCalledTimes(0); 178 | 179 | expect(myStore.list).toEqual([]); 180 | expect(myStore.setData.size).toEqual(0); 181 | 182 | runInAction(() => { 183 | myStore.list = ['test']; 184 | myStore.setData.add('item1'); 185 | myStore.setData.add('item2'); 186 | }); 187 | 188 | expect(spyOnSerialize).toBeCalledTimes(2); 189 | expect(spyOnDeserialize).toBeCalledTimes(0); 190 | 191 | storePersist.pausePersisting(); 192 | 193 | runInAction(() => { 194 | myStore.list = []; 195 | myStore.setData.clear(); 196 | }); 197 | 198 | expect(myStore.list).toEqual([]); 199 | expect(myStore.setData.size).toEqual(0); 200 | 201 | await storePersist.hydrateStore(); 202 | 203 | expect(myStore.list).toEqual(['test']); 204 | expect(myStore.setData.has('item1')).toBe(true); 205 | expect(myStore.setData.has('item2')).toBe(true); 206 | 207 | return; 208 | }); 209 | }); 210 | }); 211 | -------------------------------------------------------------------------------- /src/PersistStore.ts: -------------------------------------------------------------------------------- 1 | import { 2 | action, 3 | IReactionDisposer, 4 | isAction, 5 | isComputedProp, 6 | makeObservable, 7 | observable, 8 | ObservableMap, 9 | ObservableSet, 10 | reaction, 11 | runInAction, 12 | toJS, 13 | } from 'mobx'; 14 | import { PersistStoreMap } from './PersistStoreMap'; 15 | import { PersistenceStorageOptions, ReactionOptions } from './types'; 16 | import { StorageAdapter } from './StorageAdapter'; 17 | import { mpsConfig, mpsReactionOptions } from './configurePersistable'; 18 | import { makeSerializableProperties, SerializableProperty } from './serializableProperty'; 19 | import { 20 | actionPersistWarningIf, 21 | computedPersistWarningIf, 22 | consoleDebug, 23 | invalidStorageAdaptorWarningIf, 24 | isArrayForMap, 25 | isArrayForSet, 26 | } from './utils'; 27 | 28 | export class PersistStore { 29 | private cancelWatch: IReactionDisposer | null = null; 30 | private properties: SerializableProperty[] = []; 31 | private reactionOptions: ReactionOptions = {}; 32 | private storageAdapter: StorageAdapter | null = null; 33 | private target: T | null = null; 34 | private version: number | undefined = undefined; 35 | private readonly debugMode: boolean = false; 36 | 37 | public isHydrated = false; 38 | public isPersisting = false; 39 | public readonly storageName: string = ''; 40 | 41 | constructor(target: T, options: PersistenceStorageOptions, reactionOptions: ReactionOptions = {}) { 42 | this.target = target; 43 | this.storageName = options.name; 44 | this.version = options.version ?? mpsConfig.version; 45 | this.properties = makeSerializableProperties(options.properties); 46 | this.reactionOptions = Object.assign({ fireImmediately: true }, mpsReactionOptions, reactionOptions); 47 | this.debugMode = options.debugMode ?? mpsConfig.debugMode ?? false; 48 | this.storageAdapter = new StorageAdapter({ 49 | version: this.version, 50 | expireIn: options.expireIn ?? mpsConfig.expireIn, 51 | removeOnExpiration: options.removeOnExpiration ?? mpsConfig.removeOnExpiration ?? true, 52 | stringify: options.stringify ?? mpsConfig.stringify ?? true, 53 | storage: options.storage ? options.storage : mpsConfig.storage, 54 | debugMode: this.debugMode, 55 | }); 56 | 57 | makeObservable( 58 | this, 59 | { 60 | clearPersistedStore: action, 61 | hydrateStore: action, 62 | isHydrated: observable, 63 | isPersisting: observable, 64 | pausePersisting: action, 65 | startPersisting: action, 66 | stopPersisting: action, 67 | }, 68 | { autoBind: true, deep: false } 69 | ); 70 | 71 | invalidStorageAdaptorWarningIf(this.storageAdapter.options.storage, this.storageName); 72 | 73 | consoleDebug(this.debugMode, `${this.storageName} - (makePersistable)`, { 74 | properties: this.properties, 75 | storageAdapter: this.storageAdapter, 76 | reactionOptions: this.reactionOptions, 77 | }); 78 | } 79 | 80 | public async init(): Promise> { 81 | await this.hydrateStore(); 82 | 83 | this.startPersisting(); 84 | 85 | return this; 86 | } 87 | 88 | public async hydrateStore(): Promise { 89 | // If the user calls stopPersist and then rehydrateStore we don't want to automatically call startPersist below 90 | const isBeingWatched = Boolean(this.cancelWatch); 91 | 92 | if (this.isPersisting) { 93 | this.pausePersisting(); 94 | } 95 | 96 | runInAction(() => { 97 | this.isHydrated = false; 98 | consoleDebug(this.debugMode, `${this.storageName} - (hydrateStore) isHydrated:`, this.isHydrated); 99 | }); 100 | 101 | if (this.storageAdapter && this.target) { 102 | const data: Record | undefined = await this.storageAdapter.getItem>( 103 | this.storageName 104 | ); 105 | 106 | // Reassigning so TypeScript doesn't complain (Object is possibly 'null') about this.target within forEach 107 | const target: any = this.target; 108 | 109 | if (data) { 110 | runInAction(() => { 111 | this.properties.forEach((property) => { 112 | const allowPropertyHydration = [ 113 | property.key in target, 114 | typeof data[property.key] !== 'undefined', 115 | ].every(Boolean); 116 | 117 | if (allowPropertyHydration) { 118 | const propertyData = data[property.key]; 119 | 120 | if (target[property.key] instanceof ObservableMap && isArrayForMap(propertyData)) { 121 | target[property.key] = property.deserialize(new Map(propertyData)); 122 | } else if (target[property.key] instanceof ObservableSet && isArrayForSet(propertyData)) { 123 | target[property.key] = property.deserialize(new Set(propertyData)); 124 | } else { 125 | target[property.key] = property.deserialize(propertyData); 126 | } 127 | } 128 | }); 129 | }); 130 | } 131 | } 132 | 133 | runInAction(() => { 134 | this.isHydrated = true; 135 | consoleDebug(this.debugMode, `${this.storageName} - isHydrated:`, this.isHydrated); 136 | }); 137 | 138 | if (isBeingWatched) { 139 | this.startPersisting(); 140 | } 141 | } 142 | 143 | public startPersisting(): void { 144 | if (!this.storageAdapter || !this.target || this.cancelWatch) { 145 | return; 146 | } 147 | 148 | // Reassigning so TypeScript doesn't complain (Object is possibly 'null') about and this.target within reaction 149 | const target: any = this.target; 150 | 151 | this.cancelWatch = reaction( 152 | () => { 153 | const propertiesToWatch = {} as Record; 154 | 155 | this.properties.forEach((property) => { 156 | const isComputedProperty = isComputedProp(target, property.key); 157 | const isActionProperty = isAction(target[property.key]); 158 | 159 | computedPersistWarningIf(isComputedProperty, String(property.key)); 160 | actionPersistWarningIf(isActionProperty, String(property.key)); 161 | 162 | if (!isComputedProperty && !isActionProperty) { 163 | let propertyData = property.serialize(target[property.key]); 164 | 165 | if (propertyData instanceof ObservableMap) { 166 | const mapArray: any = []; 167 | propertyData.forEach((v, k) => { 168 | mapArray.push([k, toJS(v)]); 169 | }); 170 | propertyData = mapArray; 171 | } else if (propertyData instanceof ObservableSet) { 172 | propertyData = Array.from(propertyData).map(toJS); 173 | } 174 | 175 | propertiesToWatch[property.key] = toJS(propertyData); 176 | } 177 | }); 178 | 179 | return propertiesToWatch; 180 | }, 181 | async (dataToSave) => { 182 | if (this.storageAdapter) { 183 | await this.storageAdapter.setItem(this.storageName, dataToSave); 184 | } 185 | }, 186 | this.reactionOptions 187 | ); 188 | 189 | this.isPersisting = true; 190 | 191 | consoleDebug(this.debugMode, `${this.storageName} - (startPersisting) isPersisting:`, this.isPersisting); 192 | } 193 | 194 | public pausePersisting(): void { 195 | this.isPersisting = false; 196 | 197 | consoleDebug(this.debugMode, `${this.storageName} - pausePersisting (isPersisting):`, this.isPersisting); 198 | 199 | if (this.cancelWatch) { 200 | this.cancelWatch(); 201 | this.cancelWatch = null; 202 | } 203 | } 204 | 205 | public stopPersisting(): void { 206 | this.pausePersisting(); 207 | 208 | consoleDebug(this.debugMode, `${this.storageName} - (stopPersisting)`); 209 | 210 | PersistStoreMap.delete(this.target); 211 | 212 | this.cancelWatch = null; 213 | this.properties = []; 214 | this.reactionOptions = {}; 215 | this.storageAdapter = null; 216 | this.target = null; 217 | } 218 | 219 | public async clearPersistedStore(): Promise { 220 | if (this.storageAdapter) { 221 | consoleDebug(this.debugMode, `${this.storageName} - (clearPersistedStore)`); 222 | 223 | await this.storageAdapter.removeItem(this.storageName); 224 | } 225 | } 226 | 227 | public async getPersistedStore>(): Promise { 228 | if (this.storageAdapter) { 229 | consoleDebug(this.debugMode, `${this.storageName} - (getPersistedStore)`); 230 | 231 | // @ts-ignore 232 | return this.storageAdapter.getItem(this.storageName); 233 | } 234 | 235 | return null; 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /__tests__/StorageAdapter.test.ts: -------------------------------------------------------------------------------- 1 | import { StorageAdapter } from '../src'; 2 | import ms from 'milliseconds'; 3 | 4 | let testStorage: Record = {}; 5 | 6 | async function setItemTestHandler(name: string, content: string): Promise { 7 | Object.assign(testStorage, { [name]: content }); 8 | } 9 | 10 | async function getItemTestHandler(name: string): Promise { 11 | return testStorage[name]; 12 | } 13 | 14 | async function removeItemTestHandler(name: string): Promise { 15 | delete testStorage[name]; 16 | } 17 | 18 | describe('StorageAdapter', () => { 19 | let storageAdapter: StorageAdapter; 20 | const mockStore: Record = { 21 | 4: 'test', 22 | 5: 1, 23 | 6: 1.15, 24 | 7: { 0: 'a' }, 25 | 8: [1, 1], 26 | 9: 1e15, 27 | }; 28 | 29 | describe('stringify option equals true', () => { 30 | beforeEach(() => { 31 | testStorage = {}; 32 | storageAdapter = new StorageAdapter({ 33 | // stringify: true, // true is the default 34 | storage: { 35 | setItem: setItemTestHandler, 36 | getItem: getItemTestHandler, 37 | removeItem: removeItemTestHandler, 38 | }, 39 | }); 40 | }); 41 | 42 | describe('setItem', () => { 43 | test(`write to storage as stringify`, async () => { 44 | await storageAdapter.setItem('mockStore', mockStore); 45 | 46 | const actualResult = testStorage['mockStore']; 47 | const expectedResult = JSON.stringify(mockStore); 48 | 49 | expect(actualResult).toEqual(expectedResult); 50 | expect(typeof expectedResult).toBe('string'); 51 | }); 52 | }); 53 | 54 | describe('getItem', () => { 55 | test(`should read storage data and be an object`, async () => { 56 | await storageAdapter.setItem('mockStore', mockStore); 57 | 58 | const actualResult = await storageAdapter.getItem('mockStore'); 59 | const expectedResult = mockStore; 60 | 61 | expect(actualResult).toEqual(expectedResult); 62 | }); 63 | }); 64 | 65 | describe('removeItem', () => { 66 | test(`should read storage data and be an object`, async () => { 67 | await storageAdapter.setItem('mockStore', mockStore); 68 | await storageAdapter.removeItem('mockStore'); 69 | 70 | const actualResult = await storageAdapter.getItem('mockStore'); 71 | const expectedResult = {}; 72 | 73 | expect(actualResult).toEqual(expectedResult); 74 | }); 75 | }); 76 | }); 77 | 78 | describe('stringify option equals false', () => { 79 | beforeEach(() => { 80 | testStorage = {}; 81 | storageAdapter = new StorageAdapter({ 82 | stringify: false, 83 | storage: { 84 | setItem: setItemTestHandler, 85 | getItem: getItemTestHandler, 86 | removeItem: removeItemTestHandler, 87 | }, 88 | }); 89 | }); 90 | 91 | describe('setItem', () => { 92 | test(`write to storage as stringify`, async () => { 93 | await storageAdapter.setItem('mockStore', mockStore); 94 | 95 | const actualResult = testStorage['mockStore']; 96 | const expectedResult = mockStore; 97 | 98 | expect(actualResult).toEqual(expectedResult); 99 | expect(typeof expectedResult).toBe('object'); 100 | }); 101 | 102 | test(`should not have __mps__`, async () => { 103 | await storageAdapter.setItem('mockStore', mockStore); 104 | 105 | const actualResult = testStorage['mockStore']; 106 | 107 | expect(actualResult).not.toHaveProperty('__mps__'); 108 | }); 109 | }); 110 | 111 | describe('getItem', () => { 112 | test(`should read storage data and be an object`, async () => { 113 | await storageAdapter.setItem('mockStore', mockStore); 114 | 115 | const actualResult = await storageAdapter.getItem('mockStore'); 116 | const expectedResult = mockStore; 117 | 118 | expect(actualResult).toEqual(expectedResult); 119 | }); 120 | }); 121 | 122 | describe('removeItem', () => { 123 | test(`should read storage data and be an object`, async () => { 124 | await storageAdapter.setItem('mockStore', mockStore); 125 | await storageAdapter.removeItem('mockStore'); 126 | 127 | const actualResult = await storageAdapter.getItem('mockStore'); 128 | const expectedResult = {}; 129 | 130 | expect(actualResult).toEqual(expectedResult); 131 | }); 132 | }); 133 | }); 134 | 135 | describe('expiration option with non-expired data', () => { 136 | beforeEach(() => { 137 | testStorage = {}; 138 | storageAdapter = new StorageAdapter({ 139 | expireIn: ms.seconds(1), 140 | stringify: false, // easier to test when data is not a string 141 | storage: { 142 | setItem: setItemTestHandler, 143 | getItem: getItemTestHandler, 144 | removeItem: removeItemTestHandler, 145 | }, 146 | }); 147 | }); 148 | 149 | describe('setItem', () => { 150 | test(`should have expireInTimestamp on __mps__`, async () => { 151 | await storageAdapter.setItem('mockStore', mockStore); 152 | 153 | const actualResult = testStorage['mockStore']; 154 | 155 | expect(actualResult).toHaveProperty('__mps__'); 156 | expect(actualResult.__mps__).toHaveProperty('expireInTimestamp'); 157 | }); 158 | }); 159 | 160 | describe('getItem', () => { 161 | test(`should read non-expired data`, async () => { 162 | await storageAdapter.setItem('mockStore', mockStore); 163 | 164 | const actualResult = await storageAdapter.getItem('mockStore'); 165 | const expectedResult = { 166 | ...mockStore, 167 | __mps__: { 168 | expireInTimestamp: testStorage['mockStore'].__mps__.expireInTimestamp, 169 | }, 170 | }; 171 | 172 | expect(actualResult).toEqual(expectedResult); 173 | }); 174 | }); 175 | }); 176 | 177 | describe('expiration option with expired data', () => { 178 | beforeEach(() => { 179 | testStorage = {}; 180 | storageAdapter = new StorageAdapter({ 181 | expireIn: -1, // one millisecond before now 182 | stringify: false, // easier to test when data is not a string 183 | storage: { 184 | setItem: setItemTestHandler, 185 | getItem: getItemTestHandler, 186 | removeItem: removeItemTestHandler, 187 | }, 188 | }); 189 | }); 190 | 191 | describe('getItem', () => { 192 | test(`should return empty object`, async () => { 193 | await storageAdapter.setItem('mockStore', mockStore); 194 | 195 | const actualResult = await storageAdapter.getItem('mockStore'); 196 | const expectedResult = {}; 197 | 198 | expect(actualResult).toEqual(expectedResult); 199 | }); 200 | }); 201 | 202 | describe('check storage', () => { 203 | test(`should delete data in storage`, async () => { 204 | await storageAdapter.getItem('mockStore'); 205 | 206 | const actualResult = testStorage['mockStore']; 207 | const expectedResult = undefined; 208 | 209 | expect(actualResult).toEqual(expectedResult); 210 | }); 211 | }); 212 | }); 213 | 214 | describe('removeOnExpiration option', () => { 215 | beforeEach(() => { 216 | testStorage = {}; 217 | storageAdapter = new StorageAdapter({ 218 | expireIn: -1, // one millisecond before now 219 | stringify: false, // easier to test when data is not a string 220 | removeOnExpiration: false, 221 | storage: { 222 | setItem: setItemTestHandler, 223 | getItem: getItemTestHandler, 224 | removeItem: removeItemTestHandler, 225 | }, 226 | }); 227 | }); 228 | 229 | describe('getItem', () => { 230 | test(`should return empty object`, async () => { 231 | await storageAdapter.setItem('mockStore', mockStore); 232 | 233 | const actualResult = await storageAdapter.getItem('mockStore'); 234 | const expectedResult = {}; 235 | 236 | expect(actualResult).toEqual(expectedResult); 237 | }); 238 | }); 239 | 240 | describe('check storage', () => { 241 | test(`should not delete data in storage`, async () => { 242 | await storageAdapter.setItem('mockStore', mockStore); 243 | await storageAdapter.getItem('mockStore'); 244 | 245 | const actualResult = testStorage['mockStore']; 246 | const expectedResult = { 247 | ...mockStore, 248 | __mps__: { 249 | expireInTimestamp: testStorage['mockStore'].__mps__.expireInTimestamp, 250 | }, 251 | }; 252 | 253 | expect(actualResult).toEqual(expectedResult); 254 | }); 255 | }); 256 | }); 257 | 258 | describe('version option', () => { 259 | describe('getItem', () => { 260 | const testLocalStorage = { 261 | setItem: setItemTestHandler, 262 | getItem: getItemTestHandler, 263 | removeItem: removeItemTestHandler, 264 | }; 265 | 266 | beforeEach(() => { 267 | testStorage = {}; 268 | storageAdapter = new StorageAdapter({ 269 | stringify: false, // easier to test when data is not a string 270 | version: 1, 271 | storage: testLocalStorage, 272 | }); 273 | }); 274 | 275 | test(`should remove data from the storage when the version is changed`, async () => { 276 | await storageAdapter.setItem('mockStore', mockStore); 277 | await storageAdapter.getItem('mockStore'); 278 | 279 | storageAdapter = new StorageAdapter({ 280 | stringify: false, 281 | version: 2, 282 | storage: testLocalStorage, 283 | }); 284 | 285 | await storageAdapter.getItem('mockStore'); 286 | 287 | const actualResult = testStorage['mockStore']; 288 | const expectedResult = undefined; 289 | 290 | expect(actualResult).toEqual(expectedResult); 291 | }); 292 | 293 | test(`shouldn't remove data from the storage when the version isn't changed`, async () => { 294 | await storageAdapter.setItem('mockStore', mockStore); 295 | await storageAdapter.getItem('mockStore'); 296 | 297 | storageAdapter = new StorageAdapter({ 298 | stringify: false, 299 | version: 1, 300 | storage: testLocalStorage, 301 | }); 302 | 303 | await storageAdapter.getItem('mockStore'); 304 | 305 | const actualResult = testStorage['mockStore']; 306 | const expectedResult = { 307 | ...mockStore, 308 | __mps__: { 309 | version: 1, 310 | }, 311 | }; 312 | 313 | expect(actualResult).toEqual(expectedResult); 314 | }); 315 | 316 | test(`shouldn't remove data if the version disappeared`, async () => { 317 | await storageAdapter.setItem('mockStore', mockStore); 318 | await storageAdapter.getItem('mockStore'); 319 | 320 | storageAdapter = new StorageAdapter({ 321 | stringify: false, 322 | storage: testLocalStorage, 323 | }); 324 | 325 | await storageAdapter.getItem('mockStore'); 326 | 327 | const actualResult = testStorage['mockStore']; 328 | const expectedResult = { 329 | ...mockStore, 330 | __mps__: { 331 | version: 1, 332 | }, 333 | }; 334 | 335 | expect(actualResult).toEqual(expectedResult); 336 | }); 337 | }); 338 | }); 339 | }); 340 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 |

Mobx Persist Store

2 |

A simple way to persist and rehydrate observable properties in mobx stores

3 |
4 | npm version 7 | Total downloads on npm 10 |
11 |
:star: Star us on GitHub — it helps!
12 | 13 | ## Table of content 14 | 15 | - [Installation](#installation) 16 | - [Demo](#demo) 17 | - [Getting Started](#getting-started) 18 | - [Simple Example](#simple-example) 19 | - [Example With SerializableProperties](#example-with-serializableproperties) 20 | - [Example With All Options](#example-with-all-options) 21 | - [Global Configuration](#global-configuration) 22 | - [API](#api) 23 | - [makePersistable](#makepersistable) 24 | - [StorageOptions & ReactionOptions](#storageoptions--reactionoptions) 25 | - [isHydrated](#ishydrated) 26 | - [isPersisting](#ispersisting) 27 | - [pausePersisting](#pausepersisting) 28 | - [startPersisting](#startpersisting) 29 | - [stopPersisting](#stoppersisting) 30 | - [hydrateStore](#hydratestore-promise) 31 | - [clearPersistedStore](#clearpersistedstore-promise) 32 | - [getPersistedStore](#getpersistedstore-promise) 33 | - [PersistStoreMap](#persiststoremap) 34 | - [Links](#links) 35 | 36 | ## Installation 37 | 38 | ```text 39 | # by yarn 40 | yarn add mobx-persist-store 41 | 42 | # OR by npm 43 | npm i mobx-persist-store 44 | ``` 45 | 46 | ## Demo 47 | 48 | Mobx Persist Store with MobX 6 49 | ![demo screen shot](./demo-screen-shot.png) 50 | 51 | ## Getting Started 52 | 53 | ```javascript 54 | makePersistable(this, { name: 'SampleStore', properties: ['someProperty'], storage: window.localStorage }); 55 | ``` 56 | 57 | To simply persist your MobX store use `makePersistable`. Pass a reference of the store (`this`) as the first argument. 58 | The second argument is the [StorageOptions](#storageoptions--reactionoptions) for persisting the store data. 59 | In the example below `name`, `properties`, and `storage` properties are required but if you use [configurePersistable](#global-configuration) you can set a global storage adapter, so you only have to set it once. 60 | You can also pass a third argument ([ReactionOptions](#storageoptions--reactionoptions)) to control when data should be saved. 61 | 62 | Hydration of the store will happen automatically when `makePersistable` is created. 63 | 64 | ### Simple Example 65 | 66 | ```javascript 67 | import { makeAutoObservable } from 'mobx'; 68 | import { makePersistable } from 'mobx-persist-store'; 69 | 70 | export class SampleStore { 71 | someProperty: []; 72 | 73 | constructor() { 74 | makeAutoObservable(this); 75 | 76 | makePersistable(this, { name: 'SampleStore', properties: ['someProperty'], storage: window.localStorage }); 77 | } 78 | } 79 | ``` 80 | 81 | ### Example With SerializableProperties 82 | 83 | ```javascript 84 | import { makeAutoObservable } from 'mobx'; 85 | import { makePersistable } from 'mobx-persist-store'; 86 | 87 | export class SampleStore { 88 | someProperty: ['a', 'b', 'c']; 89 | 90 | constructor() { 91 | makeAutoObservable(this); 92 | 93 | makePersistable(this, { 94 | name: 'SampleStore', 95 | properties: [ 96 | { 97 | key: 'someProperty', 98 | serialize: (value) => { 99 | return value.join(','); 100 | }, 101 | deserialize: (value) => { 102 | return value.split(','); 103 | }, 104 | }, 105 | ], 106 | storage: window.localStorage, 107 | }); 108 | } 109 | } 110 | ``` 111 | 112 | ### Example With All Options 113 | 114 | ```javascript 115 | import { makePersistable } from 'mobx-persist-store'; 116 | import localForage from "localforage"; 117 | 118 | ... 119 | makePersistable( 120 | this, 121 | { 122 | name: 'SampleStore', 123 | properties: ['someProperty'], 124 | storage: localForage, // localForage, window.localStorage, AsyncStorage all have the same interface 125 | expireIn: 86400000, // One day in milliseconds 126 | removeOnExpiration: true, 127 | stringify: false, 128 | debugMode: true, 129 | }, 130 | { delay: 200, fireImmediately: false }, 131 | ); 132 | ... 133 | ``` 134 | 135 | ## Global Configuration 136 | 137 | If you plan on using the same values for some options you can set them globally with the `configurePersistable`. 138 | 139 | ```javascript 140 | import { configurePersistable } from 'mobx-persist-store'; 141 | 142 | // All properties are optional 143 | configurePersistable( 144 | { 145 | storage: window.localStorage, 146 | expireIn: 86400000, 147 | removeOnExpiration: true, 148 | stringify: false, 149 | debugMode: true, 150 | }, 151 | { delay: 200, fireImmediately: false } 152 | ); 153 | ``` 154 | 155 | ```javascript 156 | export class SampleStore { 157 | someProperty: []; 158 | 159 | constructor() { 160 | makeAutoObservable(this); 161 | 162 | // Now makePersistable only needs `name` and `properties`: 163 | makePersistable(this, { name: 'SampleStore', properties: ['someProperty'] }); 164 | } 165 | } 166 | ``` 167 | 168 | `configurePersistable` sets items globally, but you can override them within `makePersistable`. 169 | 170 | ## ES6 Map and Set Support 171 | 172 | This library has built-in support for persisting ES6 Map and Set objects. When a property is an instance of `ObservableMap` or `ObservableSet`, it will be automatically serialized to an array format and then deserialized back to a Map or Set when the store is hydrated. 173 | 174 | ```javascript 175 | import { makeAutoObservable } from 'mobx'; 176 | import { makePersistable } from 'mobx-persist-store'; 177 | 178 | export class SampleStore { 179 | mapProperty = new Map([ 180 | ['key1', 'value1'], 181 | ['key2', 'value2'], 182 | ]); 183 | setProperty = new Set(['item1', 'item2', 'item3']); 184 | 185 | constructor() { 186 | makeAutoObservable(this); 187 | 188 | makePersistable(this, { 189 | name: 'SampleStore', 190 | properties: ['mapProperty', 'setProperty'], 191 | storage: window.localStorage, 192 | }); 193 | } 194 | } 195 | ``` 196 | 197 | ## API 198 | 199 | You should only need `makePersistable` but this library also provides other utils for more advance usage. 200 | 201 | #### makePersistable (Promise) 202 | 203 | > **makePersistable** sets up store persisting. 204 | > 205 | > ```javascript 206 | > import { makeAutoObservable } from 'mobx'; 207 | > import { makePersistable } from 'mobx-persist-store'; 208 | > 209 | > class SampleStore { 210 | > someProperty: []; 211 | > 212 | > constructor() { 213 | > makeAutoObservable(this); 214 | > 215 | > makePersistable(this, { name: 'SampleStore', properties: ['someProperty'] }); 216 | > } 217 | > } 218 | > ``` 219 | > 220 | > `makePersistable` is a Promise, so you can determine when the store has been initially hydrated. Also, you can use [isHydrated](#ishydrated) to determine the hydration state. 221 | > 222 | > ```javascript 223 | > import { makeAutoObservable, action } from 'mobx'; 224 | > import { makePersistable } from 'mobx-persist-store'; 225 | > ... 226 | > makePersistable(this, { name: 'SampleStore', properties: ['someProperty'] }).then( 227 | > action((persistStore) => { 228 | > console.log(persistStore.isHydrated); 229 | > }) 230 | > ); 231 | > ... 232 | > ``` 233 | 234 | #### StorageOptions & ReactionOptions 235 | 236 | > **StorageOptions** 237 | > 238 | > - `name` (String) - Should be a unique identifier and will be used as the key for the data storage. 239 | > - `properties` (Array of String) - A list of observable properties on the store you want to persist. Doesn't save MobX actions or computed values. 240 | > - `storage` ([localStorage Like API](https://hacks.mozilla.org/2009/06/localstorage/)) - Facilitates the reading, writing, and removal of the persisted store data. For **ReactNative** it may be `AsyncStorage`, `FS`, etc. and for **React** - `localStorage`, `sessionStorage`, `localForage` etc. 241 | > - If you have an app that is Server-side rendering (SSR) you can set the value `undefined` to prevent errors. 242 | > - `expireIn` (Number) - A value in milliseconds to determine when the data in storage should not be retrieved by getItem. Never expires by default. 243 | > - `removeOnExpiration` (Boolean) - If expireIn has a value and has expired, the data in storage will be removed automatically when getItem is called. The default value is true. 244 | > - `stringify` (Boolean) - When true the data will be JSON.stringify before being passed to setItem. The default value is true. 245 | > - `debugMode` (Boolean) - When true a console.info will be called for several of mobx-persist-store items. The default value is false. 246 | > 247 | > **ReactionOptions** [MobX Reactions Options](https://mobx.js.org/reactions.html#options-) 248 | > 249 | > - `delay` (Number) - Allows you to set a `delay` option to limit the amount of times the `write` function is called. No delay by default. 250 | > - For example if you have a `200` millisecond delay and two changes happen within the delay time then the `write` function is only called once. If you have no delay then the `write` function would be called twice. 251 | > - `fireImmediately` (Boolean) - Determines if the store data should immediately be persisted or wait until a property in store changes. `false` by default. 252 | > 253 | > ```javascript 254 | > configurePersistable( 255 | > { 256 | > storage: window.localStorage, 257 | > expireIn: 86400000, 258 | > removeOnExpiration: true, 259 | > stringify: false, 260 | > debugMode: true, 261 | > }, 262 | > { delay: 200, fireImmediately: false } 263 | > ); 264 | > ... 265 | > makePersistable( 266 | > this, 267 | > { 268 | > name: 'SampleStore', 269 | > properties: ['someProperty'], 270 | > storage: window.localStorage, 271 | > expireIn: 86400000, 272 | > removeOnExpiration: true, 273 | > stringify: false, 274 | > debugMode: true, 275 | > }, 276 | > { delay: 200, fireImmediately: false } 277 | > ); 278 | > ``` 279 | 280 | #### isHydrated 281 | 282 | > **isHydrated** will be `true` once the store has finished being updated with the persisted data. 283 | > 284 | > ```javascript 285 | > import { makeAutoObservable } from 'mobx'; 286 | > import { makePersistable, isHydrated } from 'mobx-persist-store'; 287 | > 288 | > class SampleStore { 289 | > someProperty: []; 290 | > 291 | > constructor() { 292 | > makeAutoObservable(this, {}, { autoBind: true }); 293 | > makePersistable(this, { name: 'SampleStore', properties: ['someProperty'] }); 294 | > } 295 | > 296 | > get isHydrated() { 297 | > return isHydrated(this); 298 | > } 299 | > } 300 | > ``` 301 | 302 | #### isPersisting 303 | 304 | > **isPersisting** determines if the store is being currently persisted. 305 | > When calling `pausePersisting` the value will be `false` and `true` with `startPersisting` is called. 306 | > 307 | > ```javascript 308 | > import { makeAutoObservable } from 'mobx'; 309 | > import { makePersistable, isPersisting } from 'mobx-persist-store'; 310 | > 311 | > class SampleStore { 312 | > someProperty: []; 313 | > 314 | > constructor() { 315 | > makeAutoObservable(this, {}, { autoBind: true }); 316 | > makePersistable(this, { name: 'SampleStore', properties: ['someProperty'] }); 317 | > } 318 | > 319 | > get isPersisting() { 320 | > return isPersisting(this); 321 | > } 322 | > } 323 | > ``` 324 | 325 | #### pausePersisting 326 | 327 | > **pausePersisting** pauses the store from persisting data. 328 | > 329 | > ```javascript 330 | > import { makeAutoObservable } from 'mobx'; 331 | > import { makePersistable, pausePersisting } from 'mobx-persist-store'; 332 | > 333 | > class SampleStore { 334 | > someProperty: []; 335 | > 336 | > constructor() { 337 | > makeAutoObservable(this, {}, { autoBind: true }); 338 | > makePersistable(this, { name: 'SampleStore', properties: ['someProperty'] }); 339 | > } 340 | > 341 | > pauseStore() { 342 | > pausePersisting(this); 343 | > } 344 | > } 345 | > ``` 346 | 347 | #### startPersisting 348 | 349 | > **startPersisting** starts persisting the store data again after `pausePersisting` was called. 350 | > 351 | > ```javascript 352 | > import { makeAutoObservable } from 'mobx'; 353 | > import { makePersistable, startPersisting } from 'mobx-persist-store'; 354 | > 355 | > class SampleStore { 356 | > someProperty: []; 357 | > 358 | > constructor() { 359 | > makeAutoObservable(this, {}, { autoBind: true }); 360 | > makePersistable(this, { name: 'SampleStore', properties: ['someProperty'] }); 361 | > } 362 | > 363 | > startStore() { 364 | > startPersisting(this); 365 | > } 366 | > } 367 | > ``` 368 | 369 | #### stopPersisting 370 | 371 | > **stopPersisting** calls `pausePersisting` and internally removes reference to the store. 372 | > You should only call this function if you have store(s) that are re-created or do not live for the entire life of your application. 373 | > 374 | > ```javascript 375 | > import { makeAutoObservable } from 'mobx'; 376 | > import { makePersistable, stopPersisting } from 'mobx-persist-store'; 377 | > 378 | > class SampleStore { 379 | > someProperty: []; 380 | > 381 | > constructor() { 382 | > makeAutoObservable(this, {}, { autoBind: true }); 383 | > makePersistable(this, { name: 'SampleStore', properties: ['someProperty'] }); 384 | > } 385 | > 386 | > stopStore() { 387 | > stopPersisting(this); 388 | > } 389 | > } 390 | > ``` 391 | > 392 | > This function prevents memory leaks when you have store(s) that are removed or re-crated. 393 | > In the React example below `stopPersisting` is called when the component is unmounted. 394 | > 395 | > ```javascript 396 | > import React, { useEffect, useState } from 'react'; 397 | > import { observer } from 'mobx-react-lite'; 398 | > import { stopPersisting } from 'mobx-persist-store'; 399 | > 400 | > export const SamplePage = observer(() => { 401 | > const [localStore] = useState(() => new SampleStore()); 402 | > 403 | > useEffect(() => { 404 | > // Called when the component is unmounted 405 | > return () => localStore.stopStore(); 406 | > }, []); 407 | > 408 | > return ( 409 | >
410 | > {localStore.someProperty.map((item) => ( 411 | >
{item.name}
412 | > ))} 413 | >
414 | > ); 415 | > }); 416 | > ``` 417 | 418 | #### hydrateStore (Promise) 419 | 420 | > **hydrateStore** will update the store with the persisted data. This will happen automatically with the initial `makePersistable` call. 421 | > This function is provide to manually hydrate the store. You should not have to call it unless you are doing something that modified the persisted data outside the store. 422 | > 423 | > ```javascript 424 | > import { makeAutoObservable } from 'mobx'; 425 | > import { makePersistable, hydrateStore } from 'mobx-persist-store'; 426 | > 427 | > class SampleStore { 428 | > someProperty: []; 429 | > 430 | > constructor() { 431 | > makeAutoObservable(this, {}, { autoBind: true }); 432 | > makePersistable(this, { name: 'SampleStore', properties: ['someProperty'] }); 433 | > } 434 | > 435 | > async hydrateStore() { 436 | > await hydrateStore(this); 437 | > } 438 | > } 439 | > ``` 440 | 441 | #### clearPersistedStore (Promise) 442 | 443 | > **clearPersistedStore** will remove the persisted data. This function is provide to manually clear the store's persisted data. 444 | > 445 | > ```javascript 446 | > import { makeAutoObservable } from 'mobx'; 447 | > import { makePersistable, clearPersistedStore } from 'mobx-persist-store'; 448 | > 449 | > class SampleStore { 450 | > someProperty: []; 451 | > 452 | > constructor() { 453 | > makeAutoObservable(this, {}, { autoBind: true }); 454 | > makePersistable(this, { name: 'SampleStore', properties: ['someProperty'] }); 455 | > } 456 | > 457 | > async clearStoredData() { 458 | > await clearPersistedStore(this); 459 | > } 460 | > } 461 | > ``` 462 | 463 | #### getPersistedStore (Promise) 464 | 465 | > **getPersistedStore** will get the persisted data. This function is provide to manually get the store's persisted data. 466 | > 467 | > ```javascript 468 | > import { makeAutoObservable } from 'mobx'; 469 | > import { makePersistable, getPersistedStore } from 'mobx-persist-store'; 470 | > 471 | > class SampleStore { 472 | > someProperty: []; 473 | > 474 | > constructor() { 475 | > makeAutoObservable(this, {}, { autoBind: true }); 476 | > makePersistable(this, { name: 'SampleStore', properties: ['someProperty'] }); 477 | > } 478 | > 479 | > async getStoredData() { 480 | > return getPersistedStore(this); 481 | > } 482 | > } 483 | > ``` 484 | 485 | #### PersistStoreMap 486 | 487 | > **PersistStoreMap** is a JavaScript Map object where the key is a reference to the store, and the value is a reference to the persist store. 488 | > Note: calling `stopPersisting(this)` will remove the store and persist store references from PersistStoreMap to prevent memory leaks. 489 | > 490 | > ```javascript 491 | > import { PersistStoreMap } from 'mobx-persist-store'; 492 | > 493 | > Array.from(PersistStoreMap.values()).map((persistStore) => persistStore.getPersistedStore()); 494 | > ``` 495 | 496 | ## Links 497 | 498 | - [MobX Site](https://mobx.js.org/README.html) 499 | --------------------------------------------------------------------------------