├── logo ├── logo.png ├── logo-title-dark.png ├── logo-title-light.png ├── README.md ├── logo.svg ├── logo-title-dark.svg ├── logo-title-light.svg └── LICENSE-logo ├── playground ├── www │ ├── index.css │ └── index.html ├── dogStore.ts ├── index.tsx └── store.ts ├── types └── types.d.ts ├── src ├── create-id.ts ├── state.ts ├── constants.ts ├── devtools.ts ├── exome.ts ├── ghost.ts ├── utils │ ├── id.ts │ ├── save-state.ts │ ├── id.test.ts │ ├── wrapper.ts │ ├── load-state.ts │ ├── wrapper.test.ts │ ├── save-state.test.ts │ └── load-state.test.ts ├── exome.test.ts ├── constructor.ts ├── rxjs.ts ├── svelte.ts ├── solid.ts ├── react.ts ├── preact.ts ├── subscribe.ts ├── on-action.ts ├── lit.ts ├── jest │ ├── serializer.ts │ └── serializer.test.ts ├── vue.ts ├── middleware.ts ├── angular.ts ├── utils.ts ├── constructor.test.ts ├── devtools-redux.ts ├── utils.test.ts ├── middleware.test.ts ├── devtools-exome.ts └── on-action.test.ts ├── e2e ├── setup │ ├── www │ │ └── index.html │ └── playwright.ts ├── stores │ ├── counter.ts │ ├── recursive.ts │ └── async-store.ts ├── tsconfig.json ├── preact │ ├── counter.tsx │ ├── tsconfig.json │ └── counter.test.ts ├── react │ ├── tsconfig.json │ ├── counter.tsx │ ├── async-action.tsx │ ├── recursive.tsx │ ├── counter.test.ts │ ├── async-action.test.ts │ └── recursive.test.ts └── lit │ ├── tsconfig.json │ ├── counter.ts │ ├── async-action.ts │ ├── counter.test.ts │ ├── recursive.ts │ ├── async-action.test.ts │ └── recursive.test.ts ├── .editorconfig ├── .github ├── SECURITY.md ├── workflows │ ├── main.yml │ └── publish.yml ├── CONTRIBUTING.md └── CODE_OF_CONDUCT.md ├── scripts ├── dev.mjs ├── common.mjs └── build.mjs ├── tsconfig.json ├── biome.json ├── LICENSE ├── deno.json ├── .gitignore ├── package.json ├── CHANGELOG.md └── README.md /logo/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marcisbee/exome/HEAD/logo/logo.png -------------------------------------------------------------------------------- /playground/www/index.css: -------------------------------------------------------------------------------- 1 | /* src/style.css */ 2 | h1, 3 | p { 4 | font-family: Lato; 5 | } 6 | -------------------------------------------------------------------------------- /logo/logo-title-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marcisbee/exome/HEAD/logo/logo-title-dark.png -------------------------------------------------------------------------------- /logo/logo-title-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marcisbee/exome/HEAD/logo/logo-title-light.png -------------------------------------------------------------------------------- /types/types.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'package.json' { 2 | const content: { version: string }; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /src/create-id.ts: -------------------------------------------------------------------------------- 1 | export const createID = (): string => 2 | ( 3 | Date.now().toString(36) + ((Math.random() * 1e5) ^ 1).toString(36) 4 | ).toUpperCase(); 5 | -------------------------------------------------------------------------------- /src/state.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module exome/state 3 | */ 4 | export { saveState } from "./utils/save-state.ts"; 5 | export { loadState, registerLoadable } from "./utils/load-state.ts"; 6 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const exomeId: unique symbol = Symbol(); 2 | export const exomeName: unique symbol = Symbol(); 3 | export const FUNCTION = "function"; 4 | export const CONSTRUCTOR = "constructor"; 5 | -------------------------------------------------------------------------------- /src/devtools.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module exome/devtools 3 | */ 4 | export { exomeReduxDevtools } from "./devtools-redux.ts"; 5 | export { exomeDevtools as unstableExomeDevtools } from "./devtools-exome.ts"; 6 | -------------------------------------------------------------------------------- /e2e/setup/www/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /e2e/stores/counter.ts: -------------------------------------------------------------------------------- 1 | import { Exome } from 'exome' 2 | 3 | class CounterStore extends Exome { 4 | public count = 0 5 | 6 | public increment() { 7 | this.count += 1 8 | } 9 | } 10 | 11 | export const counter = new CounterStore() 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | indent_style = tab 8 | indent_size = 2 9 | end_of_line = lf 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [{package.json,package-lock.json}] 14 | indent_style = space 15 | 16 | [*.{yml,yaml}] 17 | indent_style = space 18 | -------------------------------------------------------------------------------- /src/exome.ts: -------------------------------------------------------------------------------- 1 | export { Exome } from "./constructor.ts"; 2 | export { subscribe, update, updateAll } from "./subscribe.ts"; 3 | export { exomeId, exomeName } from "./constants.ts"; 4 | export { onAction } from "./on-action.ts"; 5 | export { getExomeId, setExomeId } from "./utils/id.ts"; 6 | export { addMiddleware, runMiddleware, type Middleware } from "./middleware.ts"; 7 | -------------------------------------------------------------------------------- /src/ghost.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module exome/ghost 3 | */ 4 | import { exomeId } from "exome"; 5 | 6 | import { createID } from "./create-id.ts"; 7 | 8 | /** 9 | * This is a class that pretends to be Exome store, but doesn't apply same change detection logic. 10 | * This is useful for testing and mocking data. 11 | */ 12 | export class GhostExome { 13 | private [exomeId] = this.constructor.name + "-" + createID(); 14 | } 15 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Versions currently being supported with security updates. 6 | 7 | | Version | Supported | 8 | | ------- | ------------------ | 9 | | 2.0.x | :white_check_mark: | 10 | | 1.5.x | :white_check_mark: | 11 | | < 1.5 | :x: | 12 | 13 | ## Reporting a Vulnerability 14 | 15 | Please create a new issue if you find any security vulnerabilities. 16 | -------------------------------------------------------------------------------- /src/utils/id.ts: -------------------------------------------------------------------------------- 1 | import { exomeId } from "../constants.ts"; 2 | import type { Exome } from "../constructor.ts"; 3 | 4 | /** 5 | * Gets unique id of specific store instance. 6 | */ 7 | export const getExomeId = (store: Exome): string => { 8 | return store[exomeId]; 9 | }; 10 | 11 | /** 12 | * Sets custom id to specific store instance. 13 | */ 14 | export const setExomeId = (store: Exome, id: string): void => { 15 | const [name] = getExomeId(store).split("-"); 16 | store[exomeId] = `${name}-${id}`; 17 | }; 18 | -------------------------------------------------------------------------------- /e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "lib": [ 6 | "DOM", 7 | "es2015" 8 | ], 9 | "strict": true, 10 | "esModuleInterop": true, 11 | "skipLibCheck": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "jsx": "react", 14 | "paths": { 15 | "exome": [ 16 | "../src/exome.ts" 17 | ], 18 | "exome/react": [ 19 | "../src/react.ts" 20 | ] 21 | }, 22 | "allowJs": true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /e2e/preact/counter.tsx: -------------------------------------------------------------------------------- 1 | import { h, Fragment, render } from 'preact' 2 | import { useRef } from 'preact/hooks' 3 | import { useStore } from 'exome/preact' 4 | 5 | import { counter } from '../stores/counter' 6 | 7 | function App() { 8 | const { count, increment } = useStore(counter) 9 | const renders = useRef(0) 10 | 11 | renders.current += 1 12 | 13 | return ( 14 | <> 15 |

{count}

16 | {renders.current} 17 | 18 | ) 19 | } 20 | 21 | render(, document.body) 22 | -------------------------------------------------------------------------------- /e2e/react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "lib": [ 6 | "DOM", 7 | "es2015" 8 | ], 9 | "strict": true, 10 | "esModuleInterop": true, 11 | "skipLibCheck": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "jsx": "react", 14 | "paths": { 15 | "exome": [ 16 | "../../src/exome.ts" 17 | ], 18 | "exome/react": [ 19 | "../../src/react.ts" 20 | ] 21 | }, 22 | "allowJs": true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /scripts/dev.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import * as esbuild from "esbuild"; 3 | 4 | import { packagePlugin } from "./common.mjs"; 5 | 6 | const ctx = await esbuild.context({ 7 | entryPoints: ["./playground/index.tsx"], 8 | bundle: true, 9 | outdir: "playground/www", 10 | format: "esm", 11 | platform: "browser", 12 | sourcemap: "inline", 13 | plugins: [packagePlugin], 14 | }); 15 | 16 | await ctx.watch(); 17 | 18 | const { host, port } = await ctx.serve({ 19 | servedir: "playground/www", 20 | }); 21 | 22 | console.log(`http://${host}:${port}`); 23 | -------------------------------------------------------------------------------- /e2e/lit/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "lib": [ 6 | "DOM", 7 | "es2015" 8 | ], 9 | "strict": true, 10 | "esModuleInterop": true, 11 | "skipLibCheck": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "experimentalDecorators": true, 14 | "paths": { 15 | "exome": [ 16 | "../../src/exome.ts" 17 | ], 18 | "exome/lit": [ 19 | "../../src/lit.ts" 20 | ] 21 | }, 22 | "allowJs": true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /e2e/react/counter.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as ReactDom from 'react-dom/client' 3 | import { useStore } from 'exome/react' 4 | 5 | import { counter } from '../stores/counter' 6 | 7 | function App() { 8 | const { count, increment } = useStore(counter) 9 | const renders = React.useRef(0) 10 | 11 | renders.current += 1 12 | 13 | return ( 14 | <> 15 |

{count}

16 | {renders.current} 17 | 18 | ) 19 | } 20 | 21 | const root = ReactDom.createRoot(document.body); 22 | root.render(); 23 | -------------------------------------------------------------------------------- /e2e/preact/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "lib": [ 6 | "DOM", 7 | "es2015" 8 | ], 9 | "strict": true, 10 | "esModuleInterop": true, 11 | "skipLibCheck": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "jsxFactory": "h", 14 | "jsxFragmentFactory": "Fragment", 15 | "jsx": "react", 16 | "paths": { 17 | "exome": [ 18 | "../../src/exome.ts" 19 | ], 20 | "exome/preact": [ 21 | "../../src/preact.ts" 22 | ] 23 | }, 24 | "allowJs": true 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "src", 4 | "target": "es6", 5 | "module": "commonjs", 6 | "lib": ["DOM", "es2015"], 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "jsx": "react", 12 | "paths": { 13 | "exome": ["./src/exome.ts"], 14 | "exome/ghost": ["./src/ghost.ts"] 15 | }, 16 | "resolveJsonModule": true, 17 | "allowImportingTsExtensions": true, 18 | "allowJs": true, 19 | "typeRoots": ["node_modules/@types", "types"] 20 | }, 21 | "include": ["src/**/*"], 22 | "exclude": ["src/**/*.test.ts"] 23 | } 24 | -------------------------------------------------------------------------------- /e2e/stores/recursive.ts: -------------------------------------------------------------------------------- 1 | import { Exome } from 'exome' 2 | 3 | export class RecursiveStore extends Exome { 4 | constructor( 5 | public name: string, 6 | public items: RecursiveStore[] = [] 7 | ) { 8 | super() 9 | } 10 | 11 | public rename(name: string) { 12 | this.name = name 13 | } 14 | } 15 | 16 | export const refRecursiveItem = new RecursiveStore('ref', [ 17 | new RecursiveStore('first'), 18 | new RecursiveStore('second') 19 | ]) 20 | 21 | export const recursiveStore = new RecursiveStore('root', [ 22 | new RecursiveStore('one', [ 23 | refRecursiveItem 24 | ]), 25 | new RecursiveStore('two', [ 26 | refRecursiveItem 27 | ]) 28 | ]) 29 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | name: Tests 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: actions/setup-node@v1 13 | with: 14 | node-version: 20 15 | - name: install 16 | run: npm ci && npm install react preact vue 17 | - name: build 18 | run: npm run build 19 | env: 20 | CI: true 21 | - name: test 22 | run: npm run test 23 | env: 24 | CI: true 25 | - name: e2e 26 | run: npm run e2e 27 | env: 28 | CI: true 29 | - name: lint 30 | run: npm run lint 31 | env: 32 | CI: true 33 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", 3 | "linter": { 4 | "enabled": true, 5 | "rules": { 6 | "recommended": true, 7 | "suspicious": { 8 | "noExplicitAny": "off", 9 | "useValidTypeof": "off", 10 | "noAssignInExpressions": "off", 11 | "noPrototypeBuiltins": "off", 12 | "noConfusingVoidType": "off" 13 | }, 14 | "complexity": { 15 | "noForEach": "off" 16 | }, 17 | "performance": { 18 | "noDelete": "off" 19 | }, 20 | "style": { 21 | "noNonNullAssertion": "off", 22 | "useTemplate": "off", 23 | "noCommaOperator": "off" 24 | }, 25 | "correctness": { 26 | "noConstructorReturn": "off" 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /e2e/stores/async-store.ts: -------------------------------------------------------------------------------- 1 | import { Exome } from 'exome' 2 | 3 | const api = { 4 | getMessage() { 5 | return new Promise((resolve) => { 6 | setTimeout(resolve, 100, 'Hello world') 7 | }) 8 | } 9 | } 10 | 11 | class AsyncStore extends Exome { 12 | public loading = false 13 | public message: string | null = null 14 | 15 | public async getMessage() { 16 | this.message = await api.getMessage() 17 | } 18 | 19 | public async getMessageWithLoading() { 20 | this.setLoading(true) 21 | 22 | this.message = await api.getMessage() 23 | 24 | this.setLoading(false) 25 | } 26 | 27 | public setLoading(value: boolean) { 28 | this.loading = value 29 | } 30 | } 31 | 32 | export const asyncStore = new AsyncStore() 33 | -------------------------------------------------------------------------------- /src/exome.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from "uvu"; 2 | import assert from "uvu/assert"; 3 | 4 | import { Exome, addMiddleware, getExomeId, update, updateAll } from "./exome"; 5 | 6 | test("exports `Exome`", () => { 7 | assert.ok(Exome); 8 | }); 9 | 10 | test("exports `update`", () => { 11 | assert.ok(update); 12 | assert.instance(update, Function); 13 | }); 14 | 15 | test("exports `updateAll`", () => { 16 | assert.ok(updateAll); 17 | assert.instance(updateAll, Function); 18 | }); 19 | 20 | test("exports `getExomeId`", () => { 21 | assert.ok(getExomeId); 22 | assert.instance(getExomeId, Function); 23 | }); 24 | 25 | test("exports `addMiddleware`", () => { 26 | assert.ok(addMiddleware); 27 | assert.instance(addMiddleware, Function); 28 | }); 29 | 30 | test.run(); 31 | -------------------------------------------------------------------------------- /e2e/lit/counter.ts: -------------------------------------------------------------------------------- 1 | import { StoreController } from 'exome/lit' 2 | import { LitElement, html } from 'lit' 3 | import { customElement } from 'lit/decorators.js' 4 | 5 | import { counter } from '../stores/counter' 6 | 7 | @customElement('lit-app') 8 | export class LitApp extends LitElement { 9 | // Create the controller and store it 10 | private readonly counter = new StoreController(this, counter) 11 | 12 | private renders = 0 13 | 14 | // Use the controller in render() 15 | render() { 16 | const { count, increment } = this.counter.store 17 | 18 | this.renders += 1 19 | 20 | return html` 21 |

${count}

22 | ${this.renders} 23 | ` 24 | } 25 | } 26 | 27 | document.body.innerHTML = '' 28 | -------------------------------------------------------------------------------- /scripts/common.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { readFileSync } from "node:fs"; 3 | import { join } from "node:path"; 4 | 5 | /** @type {import('esbuild').Plugin} */ 6 | export const packagePlugin = { 7 | name: "package-json", 8 | setup({ onResolve, onLoad }) { 9 | onResolve({ filter: /\/package\.json$/ }, (args) => ({ 10 | namespace: "package-json", 11 | path: join(args.resolveDir, args.path), 12 | })); 13 | 14 | onLoad({ filter: /./, namespace: "package-json" }, async (args) => { 15 | try { 16 | const { version } = JSON.parse(readFileSync(args.path, "utf-8")); 17 | 18 | return { 19 | contents: JSON.stringify({ version }), 20 | loader: "json", 21 | }; 22 | } catch (err) { 23 | // err = { errors, warnings } 24 | return err; 25 | } 26 | }); 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /playground/www/index.html: -------------------------------------------------------------------------------- 1 |
2 | 23 | -------------------------------------------------------------------------------- /e2e/react/async-action.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as ReactDom from 'react-dom/client' 3 | import { useStore } from 'exome/react' 4 | 5 | import { asyncStore } from '../stores/async-store' 6 | 7 | function App() { 8 | const { message, loading, getMessage, getMessageWithLoading } = useStore(asyncStore) 9 | const renders = React.useRef(0) 10 | 11 | renders.current += 1 12 | 13 | return ( 14 | <> 15 | {loading && ()} 16 |

{message}

17 | 18 | 19 | {renders.current} 20 | 21 | ) 22 | } 23 | 24 | const root = ReactDom.createRoot(document.body); 25 | root.render(); 26 | -------------------------------------------------------------------------------- /e2e/react/recursive.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as ReactDom from 'react-dom/client' 3 | import { useStore } from 'exome/react' 4 | 5 | import { RecursiveStore, recursiveStore } from '../stores/recursive' 6 | 7 | function Item({ item }: { item: RecursiveStore }) { 8 | const { name, items, rename } = useStore(item) 9 | 10 | return ( 11 |
  • 12 | { 16 | rename(e.target.value) 17 | }} 18 | /> 19 | 20 | {items && ( 21 |
      22 | {items.map((subItem) => ( 23 | 24 | ))} 25 |
    26 | )} 27 |
  • 28 | ) 29 | } 30 | 31 | const root = ReactDom.createRoot(document.body); 32 | root.render(
    ); 33 | -------------------------------------------------------------------------------- /src/constructor.ts: -------------------------------------------------------------------------------- 1 | import { CONSTRUCTOR, exomeId, exomeName } from "./constants.ts"; 2 | import { createID } from "./create-id.ts"; 3 | import { runMiddleware } from "./middleware.ts"; 4 | import { subscriptions } from "./subscribe.ts"; 5 | import { wrapper } from "./utils/wrapper.ts"; 6 | 7 | /** 8 | * Class that every store extends from. 9 | */ 10 | export class Exome { 11 | private [exomeId]: string; 12 | private [exomeName]!: string; 13 | 14 | constructor() { 15 | const name = this[exomeName] || this[CONSTRUCTOR].name; 16 | const id = (this[exomeId] = name + "-" + createID()); 17 | const after = runMiddleware(this, "NEW", []); 18 | 19 | subscriptions[id] = new Set(); 20 | 21 | // Run this code after child constructor to get all the parameters right. 22 | Promise.resolve().then(after as any); 23 | 24 | return wrapper(this); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/rxjs.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module exome/rxjs 3 | */ 4 | import { type Exome, subscribe } from "exome"; 5 | import { Observable } from "rxjs"; 6 | 7 | /** 8 | * Subscribes to store instance update events and trigger Observable updates accordingly. 9 | * 10 | * @example: 11 | * ```ts 12 | * import { observableFromExome } from "exome/rxjs" 13 | * import { counterStore } from "./counter.store.ts" 14 | * 15 | * observableFromExome(counterStore) 16 | * .pipe( 17 | * map(({ count }) => count), 18 | * distinctUntilChanged() 19 | * ) 20 | * .subscribe((value) => { 21 | * console.log("Count changed to", value) 22 | * }); 23 | * 24 | * setInterval(counterStore.increment, 1000) 25 | * ``` 26 | */ 27 | export function observableFromExome( 28 | store: T, 29 | ): Observable { 30 | return new Observable((subscriber) => { 31 | subscribe(store, (value: any) => subscriber.next(value)); 32 | subscriber.next(store); 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /src/svelte.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module exome/svelte 3 | */ 4 | import { type Exome, subscribe } from "exome"; 5 | 6 | /** 7 | * Subscribes to store instance update events and trigger updates to component accordingly. 8 | * 9 | * @example: 10 | * ```html 11 | * 18 | * 19 | *
    20 | * 21 | *
    22 | * ``` 23 | */ 24 | export function useStore( 25 | store: T, 26 | selector: (state: T) => R = (v) => v as any, 27 | ): { 28 | subscribe(cb: (value: R) => void): () => void; 29 | } { 30 | return { 31 | subscribe(cb: (value: R) => void) { 32 | cb(selector(store)); 33 | return subscribe(store, () => cb(selector(store))); 34 | }, 35 | }; 36 | } 37 | -------------------------------------------------------------------------------- /e2e/lit/async-action.ts: -------------------------------------------------------------------------------- 1 | import { StoreController } from 'exome/lit' 2 | import { LitElement, html } from 'lit' 3 | import { customElement } from 'lit/decorators.js' 4 | 5 | import { asyncStore } from '../stores/async-store' 6 | 7 | @customElement('lit-app') 8 | export class LitApp extends LitElement { 9 | // Create the controller and store it 10 | private readonly asyncStore = new StoreController(this, asyncStore) 11 | 12 | private renders = 0 13 | 14 | // Use the controller in render() 15 | render() { 16 | const { message, loading, getMessage, getMessageWithLoading } = this.asyncStore.store 17 | 18 | this.renders += 1 19 | 20 | return html` 21 | ${loading && (html``)} 22 |

    ${message}

    23 | 24 | 25 | ${this.renders} 26 | ` 27 | } 28 | } 29 | 30 | document.body.innerHTML = '' 31 | -------------------------------------------------------------------------------- /src/solid.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module exome/solid 3 | */ 4 | import { type Exome, subscribe } from "exome"; 5 | import { type Accessor, createSignal, onCleanup } from "solid-js"; 6 | 7 | /** 8 | * Subscribes to store instance update events and trigger updates to component accordingly. 9 | * 10 | * @example: 11 | * ```ts 12 | * import { useStore } from "exome/solid" 13 | * import { counterStore } from "./counter.store.ts" 14 | * 15 | * function App() { 16 | * const { count, increment } = useStore(counterStore, s => s.count) 17 | * 18 | * return ( 19 | * 20 | * ); 21 | * } 22 | * ``` 23 | */ 24 | export function useStore( 25 | store: T, 26 | selector: (state: T) => R = (v) => v as any, 27 | ): Accessor { 28 | const [value, setValue] = createSignal(selector(store)); 29 | 30 | function render() { 31 | setValue(() => selector(store)); 32 | } 33 | 34 | const unsubscribe = subscribe(store, render); 35 | onCleanup(() => unsubscribe); 36 | 37 | return value; 38 | } 39 | -------------------------------------------------------------------------------- /src/react.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module exome/react 3 | */ 4 | import { type Exome, subscribe } from "exome"; 5 | import { useEffect, useLayoutEffect, useState } from "react"; 6 | 7 | const useIsomorphicLayoutEffect = 8 | typeof window !== "undefined" ? useLayoutEffect : useEffect; 9 | 10 | function increment(number: number): number { 11 | return number + 1; 12 | } 13 | 14 | /** 15 | * Subscribes to store instance update events and trigger updates to component accordingly. 16 | * 17 | * @example: 18 | * ```ts 19 | * import { useStore } from "exome/react" 20 | * import { counterStore } from "./counter.store.ts" 21 | * 22 | * function App() { 23 | * const { count, increment } = useStore(counterStore) 24 | * 25 | * return ( 26 | * 27 | * ); 28 | * } 29 | * ``` 30 | */ 31 | export const useStore = ( 32 | store: T, 33 | ): Readonly => { 34 | const [, render] = useState(0); 35 | 36 | useIsomorphicLayoutEffect( 37 | () => subscribe(store, () => render(increment)), 38 | [store], 39 | ); 40 | 41 | return store; 42 | }; 43 | -------------------------------------------------------------------------------- /src/preact.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module exome/preact 3 | */ 4 | import { type Exome, subscribe } from "exome"; 5 | import { useEffect, useLayoutEffect, useState } from "preact/hooks"; 6 | 7 | const useIsomorphicLayoutEffect = 8 | typeof window !== "undefined" ? useLayoutEffect : useEffect; 9 | 10 | function increment(number: number): number { 11 | return number + 1; 12 | } 13 | 14 | /** 15 | * Subscribes to store instance update events and trigger updates to component accordingly. 16 | * 17 | * @example: 18 | * ```ts 19 | * import { useStore } from "exome/preact" 20 | * import { counterStore } from "./counter.store.ts" 21 | * 22 | * function App() { 23 | * const { count, increment } = useStore(counterStore) 24 | * 25 | * return ( 26 | * 27 | * ); 28 | * } 29 | * ``` 30 | */ 31 | export function useStore( 32 | store: T, 33 | ): Readonly { 34 | const [, render] = useState(0); 35 | 36 | useIsomorphicLayoutEffect( 37 | () => subscribe(store, () => render(increment)), 38 | [store], 39 | ); 40 | 41 | return store; 42 | } 43 | -------------------------------------------------------------------------------- /src/subscribe.ts: -------------------------------------------------------------------------------- 1 | import { exomeId } from "./constants.ts"; 2 | import type { Exome } from "./constructor.ts"; 3 | 4 | export const subscriptions: Record any>> = {}; 5 | 6 | /** 7 | * Subscribe to store instance update events. 8 | */ 9 | export const subscribe = ( 10 | store: T | null | undefined, 11 | fn: (store: T) => void, 12 | ): (() => void) => { 13 | if (store == null) { 14 | return () => {}; 15 | } 16 | 17 | const set = (subscriptions[store[exomeId]] ??= new Set()); 18 | const update = () => fn(store); 19 | 20 | set.add(update); 21 | 22 | return () => { 23 | set.delete(update); 24 | }; 25 | }; 26 | 27 | /** 28 | * Sends update event to specific store instance. 29 | */ 30 | export const update = (store: Exome): void => { 31 | for (const fn of subscriptions[store[exomeId]]?.values?.() || []) { 32 | fn(); 33 | } 34 | }; 35 | 36 | /** 37 | * Sends update event to all existing store instances. 38 | */ 39 | export const updateAll = (): void => { 40 | Object.values(subscriptions).map((set) => { 41 | for (const fn of set.values()) { 42 | fn(); 43 | } 44 | }); 45 | }; 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Marcis Bergmanis 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 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://jsr.io/schema/config-file.v1.json", 3 | "name": "@exome/exome", 4 | "version": "2.8.1", 5 | "publish": { 6 | "include": ["LICENSE", "README.md", "deno.json", "package.json", "src", "logo"], 7 | "exclude": ["src/**/*.test.ts"] 8 | }, 9 | "exports": { 10 | ".": "./src/exome.ts", 11 | "./devtools": "./src/devtools.ts", 12 | "./ghost": "./src/ghost.ts", 13 | "./state": "./src/state.ts", 14 | "./utils": "./src/utils.ts", 15 | "./react": "./src/react.ts", 16 | "./preact": "./src/preact.ts", 17 | "./vue": "./src/vue.ts", 18 | "./lit": "./src/lit.ts", 19 | "./rxjs": "./src/rxjs.ts", 20 | "./svelte": "./src/svelte.ts", 21 | "./solid": "./src/solid.ts", 22 | "./angular": "./src/angular.ts" 23 | }, 24 | "imports": { 25 | "exome": "./src/exome.ts", 26 | "react": "npm:react@^18.2.0", 27 | "preact/hooks": "npm:preact@^10.19.6/hooks", 28 | "vue": "npm:vue@^3.4.21", 29 | "lit": "npm:lit@^3.1.2", 30 | "rxjs": "npm:rxjs@^7.8.1", 31 | "solid-js": "npm:solid-js@^1.8.15", 32 | "@angular/core": "npm:@angular/core@^17.3.0" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/on-action.ts: -------------------------------------------------------------------------------- 1 | import type { Exome } from "./constructor.ts"; 2 | import { addMiddleware } from "./middleware.ts"; 3 | 4 | type Unsubscribe = () => void; 5 | 6 | /** 7 | * Listens to specific actions for all instances of particular store. 8 | */ 9 | export const onAction = < 10 | T extends Exome, 11 | A extends null | "NEW" | "LOAD_STATE" | keyof T, 12 | >( 13 | Parent: new (...args: any[]) => T, 14 | action: A, 15 | callback: < 16 | P extends A extends keyof T 17 | ? T[A] extends (...args: infer P) => any 18 | ? P 19 | : any[] 20 | : any[], 21 | >( 22 | instance: T, 23 | action: Exclude, 24 | payload: P, 25 | error?: Error, 26 | response?: any, 27 | ) => void, 28 | type: "before" | "after" = "after", 29 | ): Unsubscribe => { 30 | return addMiddleware((instance, targetAction, payload) => { 31 | if ( 32 | !( 33 | instance instanceof Parent && 34 | (targetAction === action || action === null) 35 | ) 36 | ) { 37 | return; 38 | } 39 | 40 | if (type === "before") { 41 | callback(instance, targetAction as any, payload as any); 42 | return; 43 | } 44 | 45 | return (error, response) => 46 | callback(instance, targetAction as any, payload as any, error, response); 47 | }); 48 | }; 49 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-node@v1 14 | with: 15 | node-version: 20 16 | - run: npm ci 17 | - run: npm install react preact vue 18 | - run: npm run build 19 | - run: npm run lint 20 | - run: npm test 21 | - run: npm run e2e 22 | 23 | publish-npm: 24 | needs: test 25 | runs-on: ubuntu-latest 26 | permissions: 27 | contents: read 28 | id-token: write 29 | steps: 30 | - uses: actions/checkout@v2 31 | - uses: actions/setup-node@v1 32 | with: 33 | node-version: 20 34 | registry-url: https://registry.npmjs.org/ 35 | - run: npm ci 36 | - run: npm install react preact vue 37 | - run: npm run build 38 | - run: npm publish --provenance --access public ./dist 39 | 40 | publish-jsr: 41 | needs: test 42 | runs-on: ubuntu-latest 43 | permissions: 44 | contents: read 45 | id-token: write 46 | steps: 47 | - uses: actions/checkout@v4 48 | - uses: denoland/setup-deno@v1 49 | - run: deno publish 50 | -------------------------------------------------------------------------------- /e2e/lit/counter.test.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path' 2 | import { suite } from 'uvu' 3 | import assert from 'uvu/assert' 4 | 5 | import * as ENV from '../setup/playwright' 6 | import { BrowserContext } from '../setup/playwright' 7 | 8 | const entry = join(__dirname, './counter.ts') 9 | const test = suite('Counter', { entry } as any) 10 | 11 | test.before(ENV.setup) 12 | test.before.each(ENV.homepage) 13 | test.after(ENV.reset) 14 | 15 | test('renders

    with "0" inside', async({ page }) => { 16 | const counterValue = await (await page.$('h1'))!.innerText() 17 | 18 | assert.equal(counterValue, '0') 19 | }) 20 | 21 | test('increments count on click', async({ page }) => { 22 | await page.click('h1') 23 | 24 | const counterValue = await (await page.$('h1'))!.innerText() 25 | 26 | assert.equal(counterValue, '1') 27 | }) 28 | 29 | test('increments count on click multiple times', async({ page }) => { 30 | await page.click('h1') 31 | await page.click('h1') 32 | await page.click('h1') 33 | await page.click('h1') 34 | 35 | const counterValue = await (await page.$('h1'))!.innerText() 36 | const renderCount = await (await page.$('span'))!.innerText() 37 | 38 | assert.equal(counterValue, '4') 39 | assert.equal(renderCount, '5') 40 | }) 41 | 42 | test.run() 43 | -------------------------------------------------------------------------------- /e2e/preact/counter.test.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path' 2 | import { suite } from 'uvu' 3 | import assert from 'uvu/assert' 4 | 5 | import * as ENV from '../setup/playwright' 6 | import type { BrowserContext } from '../setup/playwright' 7 | 8 | const entry = join(__dirname, './counter.tsx') 9 | const test = suite('Counter', { entry } as any) 10 | 11 | test.before(ENV.setup) 12 | test.before.each(ENV.homepage) 13 | test.after(ENV.reset) 14 | 15 | test('renders

    with "0" inside', async({ page }) => { 16 | const counterValue = await (await page.$('h1'))!.innerHTML() 17 | 18 | assert.equal(counterValue, '0') 19 | }) 20 | 21 | test('increments count on click', async({ page }) => { 22 | await page.click('h1') 23 | 24 | const counterValue = await (await page.$('h1'))!.innerHTML() 25 | 26 | assert.equal(counterValue, '1') 27 | }) 28 | 29 | test('increments count on click multiple times', async({ page }) => { 30 | await page.click('h1') 31 | await page.click('h1') 32 | await page.click('h1') 33 | await page.click('h1') 34 | 35 | const counterValue = await (await page.$('h1'))!.innerHTML() 36 | const renderCount = await (await page.$('span'))!.innerHTML() 37 | 38 | assert.equal(counterValue, '4') 39 | assert.equal(renderCount, '5') 40 | }) 41 | 42 | test.run() 43 | -------------------------------------------------------------------------------- /e2e/react/counter.test.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path' 2 | import { suite } from 'uvu' 3 | import assert from 'uvu/assert' 4 | 5 | import * as ENV from '../setup/playwright' 6 | import type { BrowserContext } from '../setup/playwright' 7 | 8 | const entry = join(__dirname, './counter.tsx') 9 | const test = suite('Counter', { entry } as any) 10 | 11 | test.before(ENV.setup) 12 | test.before.each(ENV.homepage) 13 | test.after(ENV.reset) 14 | 15 | test('renders

    with "0" inside', async({ page }) => { 16 | const counterValue = await (await page.$('h1'))!.innerHTML() 17 | 18 | assert.equal(counterValue, '0') 19 | }) 20 | 21 | test('increments count on click', async({ page }) => { 22 | await page.click('h1') 23 | 24 | const counterValue = await (await page.$('h1'))!.innerHTML() 25 | 26 | assert.equal(counterValue, '1') 27 | }) 28 | 29 | test('increments count on click multiple times', async({ page }) => { 30 | await page.click('h1') 31 | await page.click('h1') 32 | await page.click('h1') 33 | await page.click('h1') 34 | 35 | const counterValue = await (await page.$('h1'))!.innerHTML() 36 | const renderCount = await (await page.$('span'))!.innerHTML() 37 | 38 | assert.equal(counterValue, '4') 39 | assert.equal(renderCount, '5') 40 | }) 41 | 42 | test.run() 43 | -------------------------------------------------------------------------------- /src/lit.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module exome/lit 3 | */ 4 | import { type Exome, subscribe } from "exome"; 5 | import type { ReactiveController, ReactiveControllerHost } from "lit"; 6 | 7 | /** 8 | * Subscribes to store instance update events and trigger updates to component accordingly. 9 | * 10 | * @example: 11 | * ```ts 12 | * import { StoreController } from "exome/lit" 13 | * import { counterStore } from "./counter.store.ts" 14 | * 15 | * @customElement("counter") 16 | * class CounterComponent extends LitElement { 17 | * private counter = new StoreController(this, counterStore) 18 | * 19 | * render() { 20 | * const { count, increment } = this.counter.store 21 | * 22 | * return html` 23 | * 24 | * ` 25 | * } 26 | * } 27 | * ``` 28 | */ 29 | export class StoreController implements ReactiveController { 30 | private unsubscribe: undefined | (() => void); 31 | 32 | constructor( 33 | private host: ReactiveControllerHost, 34 | public store: T, 35 | ) { 36 | host.addController(this); 37 | } 38 | 39 | hostConnected() { 40 | this.unsubscribe = subscribe(this.store, () => { 41 | this.host.requestUpdate(); 42 | }); 43 | } 44 | 45 | hostDisconnected() { 46 | this.unsubscribe?.(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/jest/serializer.ts: -------------------------------------------------------------------------------- 1 | import { Exome } from "exome"; 2 | import { GhostExome } from "exome/ghost"; 3 | 4 | const depthMap = new WeakMap(); 5 | 6 | export function test(val: any): boolean { 7 | return val instanceof Exome || val instanceof GhostExome; 8 | } 9 | 10 | export function print( 11 | val: typeof Exome, 12 | printDepth: (value: any) => string, 13 | ): string { 14 | const proto: Exome | GhostExome = Object.getPrototypeOf(val); 15 | const name = proto.constructor.name || "Exome"; 16 | 17 | const currentInstanceDepth = depthMap.get(val) || 0; 18 | depthMap.set(val, currentInstanceDepth + 1); 19 | 20 | try { 21 | if (currentInstanceDepth > 0) { 22 | return `${name} [circular]`; 23 | } 24 | 25 | return ( 26 | `${name} ` + 27 | printDepth( 28 | Object.entries(val) 29 | .filter(([, value]) => typeof value !== "function") 30 | .sort(([a], [b]) => (a < b ? -1 : 1)) 31 | .reduce>((acc, [key, value]) => { 32 | acc[key] = value; 33 | return acc; 34 | }, {}), 35 | ) 36 | ); 37 | } catch (e) { 38 | // biome-ignore lint/complexity/noUselessCatch: 39 | throw e; 40 | } finally { 41 | if (currentInstanceDepth === 0) { 42 | depthMap.delete(val); 43 | } else { 44 | depthMap.set(val, currentInstanceDepth); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/utils/save-state.ts: -------------------------------------------------------------------------------- 1 | import { Exome, getExomeId } from "exome"; 2 | 3 | const replacer = (): any => { 4 | const savedInstances: Record = {}; 5 | 6 | return (_: string, value: any): any => { 7 | // Found an Exome instance, replace it with simple object 8 | // that contains `$$exome_id` property. 9 | if (value instanceof Exome) { 10 | const id = getExomeId(value); 11 | 12 | if (!id) { 13 | return value; 14 | } 15 | 16 | if (savedInstances[id]) { 17 | return { 18 | $$exome_id: id, 19 | }; 20 | } 21 | 22 | savedInstances[id] = true; 23 | 24 | return { 25 | $$exome_id: id, 26 | ...value, 27 | }; 28 | } 29 | 30 | return value; 31 | }; 32 | }; 33 | 34 | /** 35 | * Saves given store instance and its children (even recursive) to string that can be later restored. 36 | * 37 | * @example: 38 | * ```ts 39 | * class CounterStore extends Exome { 40 | * public count = 5 41 | * 42 | * public increment() { 43 | * this.count += 1 44 | * } 45 | * } 46 | * 47 | * saveState(new CounterStore()) 48 | * ``` 49 | */ 50 | export const saveState = (store: Exome, readable = false): string => { 51 | const output = JSON.stringify(store, replacer(), readable ? 2 : undefined); 52 | 53 | if (output === undefined) { 54 | return "null"; 55 | } 56 | 57 | return output; 58 | }; 59 | -------------------------------------------------------------------------------- /src/vue.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module exome/vue 3 | */ 4 | import { Exome, subscribe } from "exome"; 5 | import { type Ref, ref, watchEffect } from "vue"; 6 | 7 | /** 8 | * Subscribes to store instance update events and trigger updates to component accordingly. 9 | * 10 | * @example: 11 | * ```html 12 | * 18 | * 19 | * 22 | * ``` 23 | */ 24 | export function useStore(store: T): Readonly { 25 | const refs: Record> = {}; 26 | 27 | function render() { 28 | Object.keys(refs).forEach((key) => { 29 | refs[key].value = (store as any)[key]; 30 | }); 31 | } 32 | 33 | watchEffect(() => subscribe(store, render), { 34 | flush: "pre", 35 | }); 36 | 37 | return new Proxy(store, { 38 | get(target: any, key: string) { 39 | if (target === store && typeof target[key] === "function") { 40 | return target[key]; 41 | } 42 | 43 | if (target && target[key] instanceof Exome) { 44 | return target[key]; 45 | } 46 | 47 | return refs[key] || (refs[key] = ref(target[key])); 48 | }, 49 | }); 50 | } 51 | -------------------------------------------------------------------------------- /e2e/setup/playwright.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import esbuild from 'esbuild' 3 | import playwright from 'playwright' 4 | import { Context } from 'uvu' 5 | 6 | export interface BrowserContext extends Context { 7 | entry: string 8 | server: esbuild.ServeResult 9 | ctx: esbuild.BuildContext 10 | browser: playwright.ChromiumBrowser 11 | page: playwright.Page 12 | } 13 | 14 | export async function setup(context: BrowserContext) { 15 | const ctx = await esbuild.context({ 16 | entryPoints: [ 17 | context.entry 18 | ], 19 | outfile: path.join(__dirname, 'www/app.js'), 20 | target: 'es2016', 21 | format: 'esm', 22 | platform: 'browser', 23 | minify: true, 24 | bundle: true, 25 | sourcemap: 'external' 26 | }) 27 | 28 | context.ctx = ctx 29 | 30 | await ctx.serve({ 31 | servedir: path.join(__dirname, 'www') 32 | }).then(async(server) => { 33 | context.server = server 34 | context.browser = await playwright.chromium.launch() 35 | 36 | const browserCtx = await context.browser.newContext() 37 | 38 | context.page = await browserCtx.newPage() 39 | }) 40 | } 41 | 42 | export async function reset(context: BrowserContext) { 43 | await context.browser.close() 44 | await context.ctx.dispose() 45 | } 46 | 47 | export async function homepage(context: BrowserContext) { 48 | await context.page.goto(`http://${context.server.host}:${context.server.port}`) 49 | } 50 | -------------------------------------------------------------------------------- /logo/README.md: -------------------------------------------------------------------------------- 1 | # The Exome Logo 2 | 3 | This is the only official Exome logo. 4 | Don't use any other logos to represent Exome. 5 | 6 | ## Logo 7 | 8 | Exome Logo 9 | 10 | Download as [PNG](https://raw.githubusercontent.com/Marcisbee/exome/main/logo/logo.png) or [SVG](https://raw.githubusercontent.com/Marcisbee/exome/main/logo/logo.svg). 11 | 12 | ## Logo with a Dark Title 13 | 14 | Exome Logo with Dark Title 15 | 16 | Download as [PNG](https://raw.githubusercontent.com/Marcisbee/exome/main/logo/logo-title-dark.png) or [SVG](https://raw.githubusercontent.com/Marcisbee/exome/main/logo/logo-title-dark.svg). 17 | 18 | ## Logo with a Light Title 19 | 20 | Exome Logo with Light Title 21 | 22 | _(You can't see the text but it's there, in white.)_ 23 | 24 | Download as [PNG](https://raw.githubusercontent.com/Marcisbee/exome/main/logo/logo-title-light.png) or [SVG](https://raw.githubusercontent.com/Marcisbee/exome/main/logo/logo-title-light.svg). 25 | 26 | ## Modifications 27 | 28 | Whenever possible, we ask you to use the originals provided on this page. 29 | 30 | ## Credits 31 | 32 | The Exome logo was designed by [Marcis Bergmanis](https://twitter.com/marcisbee/). 33 | 34 | ## License 35 | 36 | The Exome logo is licensed under CC0, waiving all copyright. 37 | [Read the license](../LICENSE-logo). 38 | -------------------------------------------------------------------------------- /playground/dogStore.ts: -------------------------------------------------------------------------------- 1 | import { Exome } from "../src/exome"; 2 | import { loadState, registerLoadable, saveState } from "../src/state"; 3 | 4 | export class Dog extends Exome { 5 | constructor(public name: string, public breed: string) { 6 | super(); 7 | } 8 | 9 | public rename(name: string) { 10 | this.name = name; 11 | } 12 | 13 | public changeBreed(breed: string) { 14 | this.breed = breed; 15 | } 16 | } 17 | 18 | export class Person extends Exome { 19 | constructor(public name: string, public dogs: Dog[] = []) { 20 | super(); 21 | } 22 | 23 | public rename(name: string) { 24 | this.name = name; 25 | } 26 | 27 | public addDog(dog: Dog) { 28 | this.dogs.push(dog); 29 | } 30 | } 31 | 32 | export class Store extends Exome { 33 | public persons: Person[] = []; 34 | 35 | public addPerson(person: Person) { 36 | this.persons.push(person); 37 | } 38 | } 39 | 40 | export const dogStorePre = new Store(); 41 | export const dogStore = new Store(); 42 | 43 | const dogAndyPre = new Dog("Andy", "beagle pup"); 44 | 45 | dogStorePre.addPerson(new Person("John Wick", [dogAndyPre])); 46 | 47 | dogStorePre.addPerson(new Person("Jane Doe", [dogAndyPre])); 48 | 49 | dogStorePre.addPerson(new Person("Daniel Craig")); 50 | 51 | const savedStore = saveState(dogStorePre); 52 | 53 | registerLoadable({ 54 | Person, 55 | Dog, 56 | }); 57 | 58 | loadState(dogStore, savedStore); 59 | 60 | export const dogAndy = dogStore.persons[0].dogs[0]; 61 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { FUNCTION } from "./constants.ts"; 2 | import type { Exome } from "./constructor.ts"; 3 | import { update } from "./subscribe.ts"; 4 | 5 | export type Middleware = ( 6 | instance: Exome, 7 | action: string, 8 | payload: any[], 9 | ) => void | ((error?: Error, response?: any) => void); 10 | 11 | export const middleware: Middleware[] = []; 12 | 13 | /** 14 | * Listens to middleware calls for any store instance. 15 | */ 16 | export const addMiddleware = (fn: Middleware): (() => void) => { 17 | middleware.push(fn); 18 | 19 | return () => { 20 | middleware.splice(middleware.indexOf(fn), 1); 21 | }; 22 | }; 23 | 24 | /** 25 | * Triggers middleware for particular store instance to be called. 26 | * When return function gets called, it maks that the middleware action 27 | * was completed with or without errors. 28 | */ 29 | export const runMiddleware = ( 30 | parent: Parameters[0], 31 | key: Parameters[1], 32 | args: Parameters[2], 33 | ): ((error?: Error, response?: any) => void) => { 34 | const after = middleware.map((middleware) => middleware(parent, key, args)); 35 | 36 | return (error?: Error, response?: any) => { 37 | if (key !== "NEW") update(parent); 38 | 39 | let x = 0; 40 | const l = after.length; 41 | while (x < l) { 42 | typeof after[x] === FUNCTION && 43 | (after[x] as (error?: Error, response?: any) => void)(error, response); 44 | ++x; 45 | } 46 | }; 47 | }; 48 | -------------------------------------------------------------------------------- /src/angular.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module exome/angular 3 | */ 4 | import { 5 | DestroyRef, 6 | type Signal, 7 | assertInInjectionContext, 8 | inject, 9 | signal, 10 | } from "@angular/core"; 11 | import { type Exome, subscribe } from "exome"; 12 | 13 | /** 14 | * Creates Angular signal and subscribes to store instance update events and trigger updates to component accordingly. 15 | * 16 | * @example: 17 | * ```ts 18 | * import { useStore } from "exome/angular" 19 | * import { counterStore } from "./counter.store.ts" 20 | * 21 | * @Component({ 22 | * selector: 'my-app', 23 | * template: ` 24 | * 27 | * `, 28 | * }) 29 | * export class App { 30 | * public count = useStore(counterStore, (s) => s.count) 31 | * 32 | * public increment() { 33 | * counterStore.increment() 34 | * } 35 | * } 36 | * ``` 37 | */ 38 | export function useStore( 39 | store: T, 40 | selector: (state: T) => R = (v) => v as any, 41 | ): Signal { 42 | const writableSignal = signal(selector(store)); 43 | 44 | function render() { 45 | writableSignal.set(selector(store)); 46 | } 47 | 48 | const unsubscribe = subscribe(store, render); 49 | 50 | const requiresCleanup: any = assertInInjectionContext(useStore); 51 | const cleanupRef = requiresCleanup ? inject(DestroyRef) : null; 52 | 53 | cleanupRef?.onDestroy(unsubscribe); 54 | 55 | return writableSignal.asReadonly(); 56 | } 57 | -------------------------------------------------------------------------------- /src/utils/id.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from "uvu"; 2 | import assert from "uvu/assert"; 3 | 4 | import { Exome, exomeId, getExomeId, setExomeId } from "exome"; 5 | 6 | test("exports `getExomeId`", () => { 7 | assert.ok(getExomeId); 8 | }); 9 | 10 | test("that `getExomeId` is function", () => { 11 | assert.instance(getExomeId, Function); 12 | }); 13 | 14 | test("returns `undefined` when passed empty object", () => { 15 | const output = getExomeId({} as any); 16 | 17 | assert.equal(output, undefined); 18 | }); 19 | 20 | test("returns `undefined` when passed empty class", () => { 21 | class Foo {} 22 | const output = getExomeId(new Foo() as any); 23 | 24 | assert.equal(output, undefined); 25 | }); 26 | 27 | test("returns id from Exome class", () => { 28 | class Foo extends Exome {} 29 | const output = getExomeId(new Foo()); 30 | 31 | assert.equal(typeof output, "string"); 32 | }); 33 | 34 | test("returns correct id from class", () => { 35 | class Foo extends Exome {} 36 | const foo = new Foo(); 37 | foo[exomeId] = "Foo-Test-123"; 38 | 39 | const output = getExomeId(foo); 40 | 41 | assert.equal(output, "Foo-Test-123"); 42 | }); 43 | 44 | test("exports `setExomeId`", () => { 45 | assert.ok(setExomeId); 46 | }); 47 | 48 | test("that `setExomeId` is function", () => { 49 | assert.instance(setExomeId, Function); 50 | }); 51 | 52 | test("sets correct id on exome", () => { 53 | class Foo extends Exome {} 54 | const foo = new Foo(); 55 | 56 | setExomeId(foo, "0110010101111000011011110110110101100101"); 57 | 58 | assert.equal(foo[exomeId], "Foo-0110010101111000011011110110110101100101"); 59 | }); 60 | 61 | test.run(); 62 | -------------------------------------------------------------------------------- /scripts/build.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import * as esbuild from "esbuild"; 3 | 4 | import { packagePlugin } from "./common.mjs"; 5 | 6 | /** @type {['cjs', 'esm']} */ 7 | const formats = ["cjs", "esm"]; 8 | 9 | for (const format of formats) { 10 | await esbuild 11 | .build({ 12 | entryPoints: [ 13 | // Main 14 | "./src/exome.ts", 15 | 16 | // Utilities 17 | "./src/ghost.ts", 18 | "./src/state.ts", 19 | "./src/subscribe.ts", 20 | "./src/utils.ts", 21 | "./src/react.ts", 22 | "./src/preact.ts", 23 | "./src/vue.ts", 24 | "./src/lit.ts", 25 | "./src/svelte.ts", 26 | "./src/solid.ts", 27 | "./src/angular.ts", 28 | "./src/rxjs.ts", 29 | "./src/devtools.ts", 30 | ], 31 | bundle: true, 32 | outdir: "dist", 33 | entryNames: "[dir]/[name]", 34 | outExtension: { 35 | ".js": format === "esm" ? ".mjs" : ".js", 36 | }, 37 | minifyIdentifiers: true, 38 | minifySyntax: true, 39 | sourcemap: "external", 40 | treeShaking: true, 41 | target: "es2016", 42 | format, 43 | external: [ 44 | "react", 45 | "preact", 46 | "vue", 47 | "lit", 48 | "svelte", 49 | "solid-js", 50 | "@angular/core", 51 | "rxjs", 52 | "exome", 53 | ], 54 | platform: "browser", 55 | logLevel: "info", 56 | plugins: [packagePlugin], 57 | }) 58 | .catch(() => process.exit(1)); 59 | } 60 | 61 | await esbuild 62 | .build({ 63 | entryPoints: ["src/jest/serializer.ts"], 64 | outfile: "dist/jest/serializer.js", 65 | platform: "node", 66 | format: "cjs", 67 | target: "node16", 68 | minify: true, 69 | logLevel: "info", 70 | }) 71 | .catch(() => process.exit(1)); 72 | -------------------------------------------------------------------------------- /src/utils/wrapper.ts: -------------------------------------------------------------------------------- 1 | import { CONSTRUCTOR, FUNCTION } from "../constants.ts"; 2 | import type { Exome } from "../constructor.ts"; 3 | import { runMiddleware } from "../middleware.ts"; 4 | 5 | export function getAllPropertyNames(obj: any) { 6 | const props = []; 7 | 8 | // biome-ignore lint/style/noParameterAssign: 9 | while ((obj = Object.getPrototypeOf(obj)) && obj !== Object.prototype) { 10 | props.push( 11 | ...Object.getOwnPropertyNames(obj).filter( 12 | (key) => 13 | key !== CONSTRUCTOR && 14 | obj.hasOwnProperty(key) && 15 | typeof Object.getOwnPropertyDescriptor(obj, key)?.get !== FUNCTION, 16 | ), 17 | ); 18 | } 19 | 20 | return props; 21 | } 22 | 23 | export const wrapper = (parent: T): T => { 24 | const properties = getAllPropertyNames(parent); 25 | 26 | for (const key of properties) { 27 | const value = (parent as any)[key]; 28 | 29 | if (typeof value === FUNCTION) { 30 | (parent as any)[key] = (...args: any) => { 31 | const middleware = runMiddleware(parent, key, args); 32 | try { 33 | const output = value.apply(parent, args); 34 | 35 | if (output && typeof output.then === FUNCTION) { 36 | return new Promise((resolve, reject) => { 37 | (output as Promise) 38 | .then( 39 | (result) => (middleware(undefined, result), resolve(result)), 40 | ) 41 | .catch((error) => (reject(error), middleware(error))); 42 | }); 43 | } 44 | 45 | return middleware(undefined, output), output; 46 | } catch (error) { 47 | middleware(error as Error); 48 | throw error; 49 | } 50 | }; 51 | } 52 | } 53 | 54 | return parent; 55 | }; 56 | -------------------------------------------------------------------------------- /e2e/lit/recursive.ts: -------------------------------------------------------------------------------- 1 | import { StoreController } from 'exome/lit' 2 | import { LitElement, html } from 'lit' 3 | import { customElement, property } from 'lit/decorators.js' 4 | 5 | import { RecursiveStore, recursiveStore } from '../stores/recursive' 6 | 7 | @customElement('lit-item') 8 | export class LitItem extends LitElement { 9 | @property() 10 | public item!: RecursiveStore 11 | 12 | // Create the controller and store it 13 | private recursiveStore!: StoreController 14 | 15 | connectedCallback() { 16 | this.recursiveStore = new StoreController(this, this.item) 17 | super.connectedCallback() 18 | } 19 | 20 | // Use the controller in render() 21 | render() { 22 | const { name, items, rename } = this.recursiveStore.store 23 | 24 | return html` 25 |
  • 26 | { 30 | rename(e.target.value) 31 | }} 32 | /> 33 | 34 | ${items && html` 35 |
      36 | ${items.map((subItem) => html` 37 | 38 | `)} 39 |
    40 | `} 41 |
  • 42 | ` 43 | } 44 | 45 | // Allow external css 46 | createRenderRoot() { 47 | return this 48 | } 49 | } 50 | 51 | @customElement('lit-app') 52 | export class LitApp extends LitElement { 53 | render() { 54 | return html` 55 |
    56 | ` 57 | } 58 | 59 | // Allow external css 60 | createRenderRoot() { 61 | return this 62 | } 63 | } 64 | 65 | document.body.innerHTML = '' 66 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module exome/utils 3 | */ 4 | import { type Exome, addMiddleware, getExomeId, update } from "exome"; 5 | 6 | interface ActionStatus { 7 | loading: boolean; 8 | error: false | E; 9 | response: void | R; 10 | unsubscribe: () => void; 11 | } 12 | 13 | const actionStatusCache: Record = {}; 14 | 15 | /** 16 | * Subscribes to specific action in specific instance and returns satus about that action. 17 | */ 18 | export function getActionStatus( 19 | store: T, 20 | action: keyof T, 21 | ): ActionStatus { 22 | const key = getExomeId(store) + ":" + (action as string); 23 | let cached = actionStatusCache[key]; 24 | 25 | if (cached) { 26 | return cached; 27 | } 28 | 29 | cached = actionStatusCache[key] = { 30 | loading: false, 31 | error: false, 32 | response: undefined, 33 | unsubscribe() { 34 | unsubscribe(); 35 | actionStatusCache[key] = undefined as any; 36 | }, 37 | }; 38 | 39 | let actionIndex = 0; 40 | 41 | const unsubscribe = addMiddleware((instance, targetAction, payload) => { 42 | if (instance !== store || targetAction !== action || !cached) { 43 | return; 44 | } 45 | 46 | actionIndex++; 47 | const currentActionIndex = actionIndex; 48 | cached.loading = true; 49 | cached.error = false; 50 | cached.response = undefined; 51 | 52 | update(instance); 53 | 54 | return (error, response) => { 55 | if (currentActionIndex !== actionIndex || !cached) { 56 | return; 57 | } 58 | 59 | cached.loading = false; 60 | cached.error = error || false; 61 | cached.response = response || undefined; 62 | 63 | update(instance); 64 | }; 65 | }); 66 | 67 | return cached; 68 | } 69 | -------------------------------------------------------------------------------- /e2e/lit/async-action.test.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path' 2 | import { suite } from 'uvu' 3 | import assert from 'uvu/assert' 4 | 5 | import * as ENV from '../setup/playwright' 6 | import { BrowserContext } from '../setup/playwright' 7 | 8 | const entry = join(__dirname, './async-action.ts') 9 | const test = suite('Async action', { entry } as any) 10 | 11 | test.before(ENV.setup) 12 | test.before.each(ENV.homepage) 13 | test.after(ENV.reset) 14 | 15 | test('renders nothing inside

    ', async({ page }) => { 16 | const h1 = (await page.$('h1'))! 17 | const span = (await page.$('span'))! 18 | 19 | assert.equal(await h1.innerText(), '') 20 | assert.equal(await span.innerText(), '1') 21 | }) 22 | 23 | test('renders content inside

    after action finishes', async({ page }) => { 24 | const h1 = (await page.$('h1'))! 25 | const span = (await page.$('span'))! 26 | 27 | assert.equal(await h1.innerText(), '') 28 | assert.equal(await span.innerText(), '1') 29 | 30 | await page.click('#getMessage') 31 | 32 | assert.equal(await h1.innerText(), '') 33 | assert.equal(await span.innerText(), '1') 34 | 35 | await page.waitForTimeout(100) 36 | 37 | assert.equal(await h1.innerText(), 'Hello world') 38 | assert.equal(await span.innerText(), '2') 39 | }) 40 | 41 | test('manages loading state correctly', async({ page }) => { 42 | const span = (await page.$('span'))! 43 | 44 | assert.equal((await page.$('#loading')) === null, true) 45 | assert.equal(await span.innerText(), '1') 46 | 47 | await page.click('#getMessageWithLoading') 48 | 49 | assert.equal((await page.$('#loading')) === null, false) 50 | assert.equal(await span.innerText(), '2') 51 | 52 | await page.waitForTimeout(100) 53 | 54 | assert.equal((await page.$('#loading')) === null, true) 55 | assert.equal(await span.innerText(), '4') 56 | }) 57 | 58 | test.run() 59 | -------------------------------------------------------------------------------- /e2e/react/async-action.test.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path' 2 | import { suite } from 'uvu' 3 | import assert from 'uvu/assert' 4 | 5 | import * as ENV from '../setup/playwright' 6 | import { BrowserContext } from '../setup/playwright' 7 | 8 | const entry = join(__dirname, './async-action.tsx') 9 | const test = suite('Async action', { entry } as any) 10 | 11 | test.before(ENV.setup) 12 | test.before.each(ENV.homepage) 13 | test.after(ENV.reset) 14 | 15 | test('renders nothing inside

    ', async({ page }) => { 16 | const h1 = (await page.$('h1'))! 17 | const span = (await page.$('span'))! 18 | 19 | assert.equal(await h1.innerText(), '') 20 | assert.equal(await span.innerText(), '1') 21 | }) 22 | 23 | test('renders content inside

    after action finishes', async({ page }) => { 24 | const h1 = (await page.$('h1'))! 25 | const span = (await page.$('span'))! 26 | 27 | assert.equal(await h1.innerText(), '') 28 | assert.equal(await span.innerText(), '1') 29 | 30 | await page.click('#getMessage') 31 | 32 | assert.equal(await h1.innerText(), '') 33 | assert.equal(await span.innerText(), '1') 34 | 35 | await page.waitForTimeout(100) 36 | 37 | assert.equal(await h1.innerText(), 'Hello world') 38 | assert.equal(await span.innerText(), '2') 39 | }) 40 | 41 | test('manages loading state correctly', async({ page }) => { 42 | const span = (await page.$('span'))! 43 | 44 | assert.equal((await page.$('#loading')) === null, true) 45 | assert.equal(await span.innerText(), '1') 46 | 47 | await page.click('#getMessageWithLoading') 48 | 49 | assert.equal((await page.$('#loading')) === null, false) 50 | assert.equal(await span.innerText(), '2') 51 | 52 | await page.waitForTimeout(100) 53 | 54 | assert.equal((await page.$('#loading')) === null, true) 55 | assert.equal(await span.innerText(), '3') 56 | }) 57 | 58 | test.run() 59 | -------------------------------------------------------------------------------- /src/constructor.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from "uvu"; 2 | import assert from "uvu/assert"; 3 | 4 | import { exomeId } from "./constants"; 5 | import { Exome } from "./constructor"; 6 | 7 | test("exports `Exome`", () => { 8 | assert.instance(Exome, Function); 9 | }); 10 | 11 | test("exome instance have `exomeId`", () => { 12 | const instance = new Exome(); 13 | 14 | assert.is(typeof instance[exomeId], "string"); 15 | }); 16 | 17 | test("extended exome instance have `exomeId`", () => { 18 | class Person extends Exome {} 19 | const instance = new Person(); 20 | 21 | assert.is(typeof instance[exomeId], "string"); 22 | }); 23 | 24 | test('exome instance has "Exome" in id', () => { 25 | const instance = new Exome(); 26 | 27 | assert.match(instance[exomeId], /^Exome-[A-Z0-9]+$/); 28 | }); 29 | 30 | test('extended exome instance has "Person" in id', () => { 31 | class Person extends Exome {} 32 | const instance = new Person(); 33 | 34 | assert.match(instance[exomeId], /^Person-[A-Z0-9]+$/); 35 | }); 36 | 37 | test('extended exome instance has "Person" in id', () => { 38 | class Person extends Exome {} 39 | const instance = new Person(); 40 | 41 | assert.match(instance[exomeId], /^Person-[A-Z0-9]+$/); 42 | }); 43 | 44 | test("extended another class has same id", () => { 45 | class PersonParent extends Exome {} 46 | class Person extends PersonParent {} 47 | const instance = new Person(); 48 | 49 | assert.match(instance[exomeId], /^Person-[A-Z0-9]+$/); 50 | }); 51 | 52 | test("throws error for async action", async () => { 53 | class TestStore extends Exome { 54 | public async run() { 55 | throw new Error("Poop"); 56 | } 57 | } 58 | const test1 = new TestStore(); 59 | 60 | try { 61 | await test1.run(); 62 | assert.unreachable(); 63 | } catch (err) { 64 | assert.instance(err, Error); 65 | assert.equal(err.message, "Poop"); 66 | } 67 | }); 68 | 69 | test.run(); 70 | -------------------------------------------------------------------------------- /logo/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | /dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | /e2e/setup/www/app.js 107 | /e2e/setup/www/app.js.map 108 | /playground/www/*.js 109 | /playground/dist/*.js 110 | /playground/dist/*.js.map 111 | .DS_Store 112 | -------------------------------------------------------------------------------- /e2e/lit/recursive.test.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path' 2 | import { suite } from 'uvu' 3 | import assert from 'uvu/assert' 4 | 5 | import * as ENV from '../setup/playwright' 6 | import { BrowserContext } from '../setup/playwright' 7 | 8 | const entry = join(__dirname, './recursive.ts') 9 | const test = suite('Recursive', { entry } as any) 10 | 11 | test.before(ENV.setup) 12 | test.before.each(ENV.homepage) 13 | test.after(ENV.reset) 14 | 15 | test('renders correct amount of elements', async({ page }) => { 16 | const inputs = await page.$$('input') 17 | 18 | assert.equal(inputs.length, 9) 19 | }) 20 | 21 | test('has correct values in input fields', async({ page }) => { 22 | const inputs = await page.$$('input') 23 | 24 | assert.equal(await inputs[0].getAttribute('value'), 'root') 25 | assert.equal(await inputs[1].getAttribute('value'), 'one') 26 | assert.equal(await inputs[2].getAttribute('value'), 'ref') 27 | assert.equal(await inputs[3].getAttribute('value'), 'first') 28 | assert.equal(await inputs[4].getAttribute('value'), 'second') 29 | assert.equal(await inputs[5].getAttribute('value'), 'two') 30 | assert.equal(await inputs[6].getAttribute('value'), 'ref') 31 | assert.equal(await inputs[7].getAttribute('value'), 'first') 32 | assert.equal(await inputs[8].getAttribute('value'), 'second') 33 | }) 34 | 35 | test('updates root value correctly', async({ page }) => { 36 | const inputs = await page.$$('input') 37 | 38 | assert.equal(await inputs[0].getAttribute('value'), 'root') 39 | 40 | await inputs[0].fill('Foo') 41 | 42 | assert.equal(await inputs[0].getAttribute('value'), 'Foo') 43 | assert.equal(await inputs[1].getAttribute('value'), 'one') 44 | assert.equal(await inputs[2].getAttribute('value'), 'ref') 45 | assert.equal(await inputs[3].getAttribute('value'), 'first') 46 | assert.equal(await inputs[4].getAttribute('value'), 'second') 47 | assert.equal(await inputs[5].getAttribute('value'), 'two') 48 | assert.equal(await inputs[6].getAttribute('value'), 'ref') 49 | assert.equal(await inputs[7].getAttribute('value'), 'first') 50 | assert.equal(await inputs[8].getAttribute('value'), 'second') 51 | }) 52 | 53 | test('updates reference value correctly', async({ page }) => { 54 | const inputs = await page.$$('input') 55 | 56 | assert.equal(await inputs[2].getAttribute('value'), 'ref') 57 | assert.equal(await inputs[6].getAttribute('value'), 'ref') 58 | 59 | await inputs[2].fill('Bar') 60 | 61 | assert.equal(await inputs[2].getAttribute('value'), 'Bar') 62 | assert.equal(await inputs[6].getAttribute('value'), 'Bar') 63 | }) 64 | 65 | test.run() 66 | -------------------------------------------------------------------------------- /e2e/react/recursive.test.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path' 2 | import { suite } from 'uvu' 3 | import assert from 'uvu/assert' 4 | 5 | import * as ENV from '../setup/playwright' 6 | import { BrowserContext } from '../setup/playwright' 7 | 8 | const entry = join(__dirname, './recursive.tsx') 9 | const test = suite('Recursive', { entry } as any) 10 | 11 | test.before(ENV.setup) 12 | test.before.each(ENV.homepage) 13 | test.after(ENV.reset) 14 | 15 | test('renders correct amount of elements', async({ page }) => { 16 | const inputs = await page.$$('input') 17 | 18 | assert.equal(inputs.length, 9) 19 | }) 20 | 21 | test('has correct values in input fields', async({ page }) => { 22 | const inputs = await page.$$('input') 23 | 24 | assert.equal(await inputs[0].getAttribute('value'), 'root') 25 | assert.equal(await inputs[1].getAttribute('value'), 'one') 26 | assert.equal(await inputs[2].getAttribute('value'), 'ref') 27 | assert.equal(await inputs[3].getAttribute('value'), 'first') 28 | assert.equal(await inputs[4].getAttribute('value'), 'second') 29 | assert.equal(await inputs[5].getAttribute('value'), 'two') 30 | assert.equal(await inputs[6].getAttribute('value'), 'ref') 31 | assert.equal(await inputs[7].getAttribute('value'), 'first') 32 | assert.equal(await inputs[8].getAttribute('value'), 'second') 33 | }) 34 | 35 | test('updates root value correctly', async({ page }) => { 36 | const inputs = await page.$$('input') 37 | 38 | assert.equal(await inputs[0].getAttribute('value'), 'root') 39 | 40 | await inputs[0].fill('Foo') 41 | 42 | assert.equal(await inputs[0].getAttribute('value'), 'Foo') 43 | assert.equal(await inputs[1].getAttribute('value'), 'one') 44 | assert.equal(await inputs[2].getAttribute('value'), 'ref') 45 | assert.equal(await inputs[3].getAttribute('value'), 'first') 46 | assert.equal(await inputs[4].getAttribute('value'), 'second') 47 | assert.equal(await inputs[5].getAttribute('value'), 'two') 48 | assert.equal(await inputs[6].getAttribute('value'), 'ref') 49 | assert.equal(await inputs[7].getAttribute('value'), 'first') 50 | assert.equal(await inputs[8].getAttribute('value'), 'second') 51 | }) 52 | 53 | test('updates reference value correctly', async({ page }) => { 54 | const inputs = await page.$$('input') 55 | 56 | assert.equal(await inputs[2].getAttribute('value'), 'ref') 57 | assert.equal(await inputs[6].getAttribute('value'), 'ref') 58 | 59 | await inputs[2].fill('Bar') 60 | 61 | assert.equal(await inputs[2].getAttribute('value'), 'Bar') 62 | assert.equal(await inputs[6].getAttribute('value'), 'Bar') 63 | }) 64 | 65 | test.run() 66 | -------------------------------------------------------------------------------- /playground/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { StrictMode } from "react"; 2 | import ReactDom from "react-dom/client"; 3 | 4 | import { useStore } from "../src/react"; 5 | 6 | import { Dog, dogAndy, dogStore, Person } from "./dogStore"; 7 | import { counterStore, jokeStore } from "./store"; 8 | 9 | function Joke() { 10 | const { joke, loading, getJoke } = useStore(jokeStore); 11 | 12 | return ( 13 |
    14 | {joke && ( 15 |
      16 |
    • {joke.setup}
    • 17 |
    • {joke.punchline}
    • 18 |
    19 | )} 20 | 21 | 24 |
    25 | ); 26 | } 27 | 28 | function Counter() { 29 | const { count, increment, decrement, reset } = useStore(counterStore); 30 | 31 | return ( 32 |
    33 |

    {count}

    34 | 35 | 36 | 37 | 38 |
    39 | ); 40 | } 41 | 42 | function Dogs({ store }: { store: Dog }) { 43 | const dog = useStore(store); 44 | 45 | return {dog.name}; 46 | } 47 | 48 | function People({ store }: { store: Person }) { 49 | const person = useStore(store); 50 | 51 | return ( 52 |
    53 | {person.name} 54 | {person.dogs.length ? ( 55 |
    56 | - owner of:{" "} 57 | {person.dogs.map((dog, index) => ( 58 | 59 | ))} 60 |
    61 | ) : ( 62 |
    - owns no dogs
    63 | )} 64 | 65 | 68 |
    69 |
    70 |
    71 | ); 72 | } 73 | 74 | function DogOwners() { 75 | const { persons, addPerson } = useStore(dogStore); 76 | 77 | return ( 78 |
    79 | {persons.map((person, index) => ( 80 | 81 | ))} 82 | 83 | 86 | 87 | 88 |
    89 | ); 90 | } 91 | 92 | function App() { 93 | return ( 94 |
    95 | 96 | 97 |
    98 |
    99 |
    100 | 101 | 102 | 103 |
    104 |
    105 |
    106 | 107 | 108 |
    109 | ); 110 | } 111 | 112 | ReactDom.createRoot(document.getElementById("root")!).render( 113 | 114 | 115 | , 116 | ); 117 | -------------------------------------------------------------------------------- /playground/store.ts: -------------------------------------------------------------------------------- 1 | import { Exome, addMiddleware, getExomeId, update } from "../src/exome"; 2 | import { unstableExomeDevtools } from "exome/devtools"; 3 | 4 | addMiddleware( 5 | unstableExomeDevtools({ 6 | name: "Exome Playground", 7 | }), 8 | ); 9 | 10 | interface Joke { 11 | id: number; 12 | type: string; 13 | setup: string; 14 | punchline: string; 15 | } 16 | 17 | export class JokeStore extends Exome { 18 | public joke: Joke | null = null; 19 | public get loading() { 20 | return getActionStatus(this, "getJoke").loading; 21 | } 22 | public get error() { 23 | return getActionStatus(this, "getJoke").error; 24 | } 25 | 26 | public async getJoke() { 27 | this.joke = await fetch( 28 | "https://official-joke-api.appspot.com/random_joke", 29 | ).then((response) => response.json()); 30 | } 31 | } 32 | 33 | export const jokeStore = new JokeStore(); 34 | 35 | export class CounterStore extends Exome { 36 | public count = 0; 37 | 38 | public increment() { 39 | this.count++; 40 | } 41 | 42 | public decrement() { 43 | this.count--; 44 | } 45 | 46 | public reset() { 47 | this.count = 0; 48 | } 49 | } 50 | 51 | export const counterStore = new CounterStore(); 52 | 53 | class Person extends Exome { 54 | constructor(public name: string, public friends: Person[]) { 55 | super(); 56 | } 57 | 58 | public addFriend(friend: Person) { 59 | this.friends.push(friend); 60 | } 61 | } 62 | 63 | export const circularStore = new Person("John", []); 64 | 65 | circularStore.addFriend(circularStore); 66 | 67 | interface ActionStatusCacheObject { 68 | loading: boolean; 69 | error: false | Error; 70 | unsubscribe: () => void; 71 | } 72 | 73 | const actionStatusCache: Record = {}; 74 | function getActionStatus(store: T, action: keyof T) { 75 | const key = getExomeId(store) + ":" + (action as string); 76 | let cached = actionStatusCache[key]; 77 | 78 | if (cached) { 79 | return cached; 80 | } 81 | 82 | cached = actionStatusCache[key] = { 83 | loading: false, 84 | error: false, 85 | unsubscribe() { 86 | unsubscribe(); 87 | actionStatusCache[key] = undefined as any; 88 | }, 89 | }; 90 | 91 | let actionIndex = 0; 92 | 93 | const unsubscribe = addMiddleware((instance, targetAction, payload) => { 94 | if (instance !== store || targetAction !== action || !cached) { 95 | return; 96 | } 97 | 98 | actionIndex++; 99 | const currentActionIndex = actionIndex; 100 | cached.loading = true; 101 | cached.error = false; 102 | 103 | update(instance); 104 | 105 | return (error) => { 106 | if (currentActionIndex !== actionIndex || !cached) { 107 | return; 108 | } 109 | 110 | cached.loading = false; 111 | cached.error = error || false; 112 | 113 | update(instance); 114 | }; 115 | }); 116 | 117 | return cached; 118 | } 119 | -------------------------------------------------------------------------------- /src/utils/load-state.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type Exome, 3 | exomeId, 4 | exomeName, 5 | runMiddleware, 6 | updateAll, 7 | } from "exome"; 8 | 9 | const loadableExomes: Record = {}; 10 | 11 | /** 12 | * For `loadState`` to know what stores to build instances from, we must make sure we register them. 13 | * 14 | * @example: 15 | * ```ts 16 | * registerLoadable({ 17 | * CounterStore, 18 | * }) 19 | * ``` 20 | */ 21 | export const registerLoadable = (config: Record): void => { 22 | Object.keys(config).forEach((key) => { 23 | config[key].prototype[exomeName] = key; 24 | 25 | loadableExomes[key] = config[key]; 26 | }); 27 | }; 28 | 29 | /** 30 | * Loads saved store into existing store instance. It will rebuild all children stores too. 31 | * 32 | * @example: 33 | * ```ts 34 | * const counterStore = new CounterStore() 35 | * const savedStore = `{"$$exome_id":"CounterStore-LS5WUJPF17SF","count":200}` 36 | * 37 | * loadState(counterStore, savedStore) 38 | * ``` 39 | */ 40 | export const loadState = (store: Exome, state: string): any => { 41 | if (!state || typeof state !== "string") { 42 | throw new Error( 43 | `State was not loaded. Passed state must be string, instead received "${typeof state}".`, 44 | ); 45 | } 46 | 47 | const instances = new Map(); 48 | 49 | const output = JSON.parse(state, (key, value) => { 50 | if (key !== "" && value && typeof value === "object" && value.$$exome_id) { 51 | const { 52 | $$exome_id: localId, 53 | ...state 54 | }: { $$exome_id: string; [key: string]: any } = value; 55 | 56 | const cachedInstance = instances.get(localId); 57 | 58 | if (cachedInstance) { 59 | for (const key in state) { 60 | if ((cachedInstance as any)[key] !== state[key]) { 61 | (cachedInstance as any)[key] = state[key]; 62 | } 63 | } 64 | return cachedInstance; 65 | } 66 | 67 | const [name] = localId.split("-"); 68 | const StoreExome = loadableExomes[name]; 69 | 70 | if (!StoreExome) { 71 | throw new Error( 72 | `State cannot be loaded! "${name}" was not registered via \`registerLoadable\`.`, 73 | ); 74 | } 75 | 76 | try { 77 | const instance = new StoreExome(); 78 | 79 | const after = runMiddleware(instance, "LOAD_STATE", []); 80 | 81 | instance[exomeId] = localId; 82 | 83 | Object.assign(instance, state); 84 | 85 | instances.set(localId, instance); 86 | 87 | after(); 88 | 89 | return instance; 90 | } catch (e) { 91 | throw new Error( 92 | `State cannot be loaded! "${name}.constructor" has logic that prevents state from being loaded.`, 93 | ); 94 | } 95 | } 96 | 97 | return value; 98 | }); 99 | 100 | if (!output?.$$exome_id) { 101 | throw new Error( 102 | "State was not loaded. Passed state string is not saved Exome instance.", 103 | ); 104 | } 105 | 106 | const { $$exome_id: rootId, ...data } = output; 107 | 108 | const after = runMiddleware(store, "LOAD_STATE", []); 109 | 110 | Object.assign(store, data); 111 | 112 | after(); 113 | 114 | // Run view update after state has been loaded 115 | updateAll(); 116 | 117 | return data; 118 | }; 119 | -------------------------------------------------------------------------------- /src/utils/wrapper.test.ts: -------------------------------------------------------------------------------- 1 | import { suite } from "uvu"; 2 | import assert from "uvu/assert"; 3 | 4 | import { getAllPropertyNames, wrapper } from "./wrapper.ts"; 5 | 6 | { 7 | const test = suite("wrapper"); 8 | 9 | test("exports `wrapper`", () => { 10 | assert.ok(wrapper); 11 | }); 12 | 13 | test("that `wrapper` is function", () => { 14 | assert.instance(wrapper, Function); 15 | }); 16 | 17 | test("doesn't throw with sync success action", () => { 18 | class TestStore { 19 | constructor() { 20 | return wrapper(this as any); 21 | } 22 | public test() {} 23 | } 24 | const testStore = new TestStore(); 25 | 26 | assert.not.throws(testStore.test); 27 | }); 28 | 29 | test("doesn't throw with async success action", async () => { 30 | class TestStore { 31 | constructor() { 32 | return wrapper(this as any); 33 | } 34 | public async test() { 35 | await Promise.resolve(); 36 | } 37 | } 38 | const testStore = new TestStore(); 39 | 40 | try { 41 | await testStore.test(); 42 | assert.ok(true); 43 | } catch (err) { 44 | assert.instance(err, Error); 45 | assert.equal(err.message, "test error"); 46 | } 47 | }); 48 | 49 | test("throws with sync error action", () => { 50 | class TestStore { 51 | constructor() { 52 | return wrapper(this as any); 53 | } 54 | public test() { 55 | throw new Error("test error"); 56 | } 57 | } 58 | const testStore = new TestStore(); 59 | 60 | assert.throws(testStore.test); 61 | }); 62 | 63 | test("throws with async error action", async () => { 64 | class TestStore { 65 | constructor() { 66 | return wrapper(this as any); 67 | } 68 | public async test() { 69 | await Promise.resolve(); 70 | throw new Error("test error"); 71 | } 72 | } 73 | const testStore = new TestStore(); 74 | 75 | try { 76 | await testStore.test(); 77 | assert.unreachable(); 78 | } catch (err) { 79 | assert.instance(err, Error); 80 | assert.equal(err.message, "test error"); 81 | } 82 | }); 83 | 84 | test.run(); 85 | } 86 | 87 | { 88 | const test = suite("getAllPropertyNames"); 89 | 90 | test("exports `getAllPropertyNames`", () => { 91 | assert.ok(getAllPropertyNames); 92 | }); 93 | 94 | test("that `getAllPropertyNames` is function", () => { 95 | assert.instance(getAllPropertyNames, Function); 96 | }); 97 | 98 | test("returns all actions from TestStore", () => { 99 | class TestStore { 100 | constructor() { 101 | return wrapper(this as any); 102 | } 103 | public test1() {} 104 | public test2 = () => {}; 105 | public test3 = 1; 106 | public get test4() { 107 | return 1; 108 | } 109 | public set test5(v: any) {} 110 | } 111 | const testStore = new TestStore(); 112 | 113 | const properties = getAllPropertyNames(testStore); 114 | 115 | assert.equal(properties, ["test1", "test5"]); 116 | }); 117 | 118 | test("returns all actions from TestStoreParent", () => { 119 | class TestStoreParent { 120 | constructor() { 121 | return wrapper(this as any); 122 | } 123 | public test1() {} 124 | public test2 = () => {}; 125 | public test3 = 1; 126 | public get test4() { 127 | return 1; 128 | } 129 | public set test5(v: any) {} 130 | } 131 | class TestStore extends TestStoreParent {} 132 | const testStore = new TestStore(); 133 | 134 | const properties = getAllPropertyNames(testStore); 135 | 136 | assert.equal(properties, ["test1", "test5"]); 137 | }); 138 | 139 | test("returns all actions from TestStoreParent2", () => { 140 | class TestStoreParent2 { 141 | constructor() { 142 | return wrapper(this as any); 143 | } 144 | public test1() {} 145 | public test2 = () => {}; 146 | public test3 = 1; 147 | public get test4() { 148 | return 1; 149 | } 150 | public set test5(v: any) {} 151 | } 152 | class TestStoreParent extends TestStoreParent2 {} 153 | class TestStore extends TestStoreParent {} 154 | const testStore = new TestStore(); 155 | 156 | const properties = getAllPropertyNames(testStore); 157 | 158 | assert.equal(properties, ["test1", "test5"]); 159 | }); 160 | 161 | test.run(); 162 | } 163 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "exome", 3 | "version": "2.8.1", 4 | "description": "State manager for deeply nested states", 5 | "main": "./dist/exome.js", 6 | "module": "./dist/exome.mjs", 7 | "types": "./dist/exome.d.ts", 8 | "scripts": { 9 | "build": "node ./scripts/build.mjs && npm run declarations", 10 | "dev": "node ./scripts/dev.mjs", 11 | "lint": "biome ci src scripts", 12 | "lint:apply": "biome check src scripts --write --unsafe", 13 | "test": "uvu -r esbuild-register -i e2e", 14 | "e2e": "uvu -r esbuild-register -i src", 15 | "coverage": "c8 --check-coverage npm test", 16 | "declarations": "tsc --declarationDir dist --emitDeclarationOnly --declaration", 17 | "postbuild": "cat package.json | sed '/\\\"devDependencies\\\"/,/}/ d; /^$/d' | sed 's/\\.\\/dist\\//\\.\\//g' > ./dist/package.json && cp README.md dist && cp LICENSE dist" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/Marcisbee/exome.git" 22 | }, 23 | "keywords": [ 24 | "store", 25 | "state", 26 | "state-manager", 27 | "deep", 28 | "nested", 29 | "react", 30 | "preact", 31 | "vue", 32 | "lit", 33 | "rxjs", 34 | "svelte" 35 | ], 36 | "author": "Marcis ", 37 | "license": "MIT", 38 | "bugs": { 39 | "url": "https://github.com/Marcisbee/exome/issues" 40 | }, 41 | "homepage": "https://github.com/Marcisbee/exome#readme", 42 | "devDependencies": { 43 | "@angular/core": "^16.2.11", 44 | "@biomejs/biome": "^1.9.4", 45 | "@types/node": "^22.10.1", 46 | "@types/proxyquire": "^1.3.28", 47 | "@types/react": "^18.3.3", 48 | "@types/react-dom": "^18.3.0", 49 | "@types/sinon": "^10.0.13", 50 | "c8": "^10.1.2", 51 | "esbuild": "^0.24.0", 52 | "esbuild-register": "^3.6.0", 53 | "lit": "^2.2.8", 54 | "playwright": "^1.24.1", 55 | "preact": "^10.10.0", 56 | "proxyquire": "^2.1.3", 57 | "react": "^18.3.1", 58 | "react-dom": "^18.3.1", 59 | "rxjs": "^7.0.0", 60 | "sinon": "^19.0.2", 61 | "solid-js": "^1.8.4", 62 | "typescript": "^5.7.2", 63 | "uvu": "^0.5.6", 64 | "vue": "^3.2.37" 65 | }, 66 | "exports": { 67 | "./package.json": "./dist/package.json", 68 | ".": { 69 | "types": "./dist/exome.d.ts", 70 | "require": "./dist/exome.js", 71 | "import": "./dist/exome.mjs" 72 | }, 73 | "./devtools": { 74 | "types": "./dist/devtools.d.ts", 75 | "require": "./dist/devtools.js", 76 | "import": "./dist/devtools.mjs" 77 | }, 78 | "./ghost": { 79 | "types": "./dist/ghost.d.ts", 80 | "require": "./dist/ghost.js", 81 | "import": "./dist/ghost.mjs" 82 | }, 83 | "./state": { 84 | "types": "./dist/state.d.ts", 85 | "require": "./dist/state.js", 86 | "import": "./dist/state.mjs" 87 | }, 88 | "./utils": { 89 | "types": "./dist/utils.d.ts", 90 | "require": "./dist/utils.js", 91 | "import": "./dist/utils.mjs" 92 | }, 93 | "./react": { 94 | "types": "./dist/react.d.ts", 95 | "require": "./dist/react.js", 96 | "import": "./dist/react.mjs" 97 | }, 98 | "./preact": { 99 | "types": "./dist/preact.d.ts", 100 | "require": "./dist/preact.js", 101 | "import": "./dist/preact.mjs" 102 | }, 103 | "./vue": { 104 | "types": "./dist/vue.d.ts", 105 | "require": "./dist/vue.js", 106 | "import": "./dist/vue.mjs" 107 | }, 108 | "./lit": { 109 | "types": "./dist/lit.d.ts", 110 | "require": "./dist/lit.js", 111 | "import": "./dist/lit.mjs" 112 | }, 113 | "./rxjs": { 114 | "types": "./dist/rxjs.d.ts", 115 | "require": "./dist/rxjs.js", 116 | "import": "./dist/rxjs.mjs" 117 | }, 118 | "./svelte": { 119 | "types": "./dist/svelte.d.ts", 120 | "require": "./dist/svelte.js", 121 | "import": "./dist/svelte.mjs" 122 | }, 123 | "./solid": { 124 | "types": "./dist/solid.d.ts", 125 | "require": "./dist/solid.js", 126 | "import": "./dist/solid.mjs" 127 | }, 128 | "./angular": { 129 | "types": "./dist/angular.d.ts", 130 | "require": "./dist/angular.js", 131 | "import": "./dist/angular.mjs" 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, please first discuss the change you wish to make via issue, 4 | email, or any other method with the owners of this repository before making a change. 5 | 6 | Please note we have a code of conduct, please follow it in all your interactions with the project. 7 | 8 | ## Pull Request Process 9 | 10 | 1. Ensure any install or build dependencies are removed before the end of the layer when doing a 11 | build. 12 | 2. Update the README.md with details of changes to the interface, this includes new environment 13 | variables, exposed ports, useful file locations and container parameters. 14 | 3. Increase the version numbers in any examples files and the README.md to the new version that this 15 | Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/). 16 | 4. You may merge the Pull Request in once you have the sign-off of two other developers, or if you 17 | do not have permission to do that, you may request the second reviewer to merge it for you. 18 | 19 | ## Code of Conduct 20 | 21 | ### Our Pledge 22 | 23 | In the interest of fostering an open and welcoming environment, we as 24 | contributors and maintainers pledge to making participation in our project and 25 | our community a harassment-free experience for everyone, regardless of age, body 26 | size, disability, ethnicity, gender identity and expression, level of experience, 27 | nationality, personal appearance, race, religion, or sexual identity and 28 | orientation. 29 | 30 | ### Our Standards 31 | 32 | Examples of behavior that contributes to creating a positive environment 33 | include: 34 | 35 | * Using welcoming and inclusive language 36 | * Being respectful of differing viewpoints and experiences 37 | * Gracefully accepting constructive criticism 38 | * Focusing on what is best for the community 39 | * Showing empathy towards other community members 40 | 41 | Examples of unacceptable behavior by participants include: 42 | 43 | * The use of sexualized language or imagery and unwelcome sexual attention or 44 | advances 45 | * Trolling, insulting/derogatory comments, and personal or political attacks 46 | * Public or private harassment 47 | * Publishing others' private information, such as a physical or electronic 48 | address, without explicit permission 49 | * Other conduct which could reasonably be considered inappropriate in a 50 | professional setting 51 | 52 | ### Our Responsibilities 53 | 54 | Project maintainers are responsible for clarifying the standards of acceptable 55 | behavior and are expected to take appropriate and fair corrective action in 56 | response to any instances of unacceptable behavior. 57 | 58 | Project maintainers have the right and responsibility to remove, edit, or 59 | reject comments, commits, code, wiki edits, issues, and other contributions 60 | that are not aligned to this Code of Conduct, or to ban temporarily or 61 | permanently any contributor for other behaviors that they deem inappropriate, 62 | threatening, offensive, or harmful. 63 | 64 | ### Scope 65 | 66 | This Code of Conduct applies both within project spaces and in public spaces 67 | when an individual is representing the project or its community. Examples of 68 | representing a project or community include using an official project e-mail 69 | address, posting via an official social media account, or acting as an appointed 70 | representative at an online or offline event. Representation of a project may be 71 | further defined and clarified by project maintainers. 72 | 73 | ### Enforcement 74 | 75 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 76 | reported by contacting the project team. All 77 | complaints will be reviewed and investigated and will result in a response that 78 | is deemed necessary and appropriate to the circumstances. The project team is 79 | obligated to maintain confidentiality with regard to the reporter of an incident. 80 | Further details of specific enforcement policies may be posted separately. 81 | 82 | Project maintainers who do not follow or enforce the Code of Conduct in good 83 | faith may face temporary or permanent repercussions as determined by other 84 | members of the project's leadership. 85 | 86 | ### Attribution 87 | 88 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 89 | available at [http://contributor-covenant.org/version/1/4][version] 90 | 91 | [homepage]: http://contributor-covenant.org 92 | [version]: http://contributor-covenant.org/version/1/4/ 93 | -------------------------------------------------------------------------------- /src/devtools-redux.ts: -------------------------------------------------------------------------------- 1 | import { Exome, type Middleware, getExomeId, updateAll } from "exome"; 2 | 3 | interface ReduxMessage { 4 | type: string; 5 | state: string; 6 | } 7 | 8 | interface ReduxAction { 9 | type: string; 10 | payload: any; 11 | } 12 | 13 | interface Redux { 14 | subscribe: (cb: (message: ReduxMessage) => void) => void; 15 | send: (action: ReduxAction, state: Record) => void; 16 | init: (state: Record) => void; 17 | } 18 | 19 | interface ReduxConfig { 20 | name?: string; 21 | maxAge?: number; 22 | actionsBlacklist?: string | string[]; 23 | serialize?: { 24 | replacer?: (key: string, value: any) => any; 25 | reviver?: (key: string, value: any) => any; 26 | }; 27 | } 28 | 29 | const fullStore: Map> = new Map(); 30 | 31 | function deepCloneStore(value: any, depth: string[] = []): any { 32 | if (value == null || typeof value !== "object") { 33 | return value; 34 | } 35 | 36 | if (value instanceof Exome && getExomeId(value)) { 37 | const id = getExomeId(value); 38 | 39 | // Stop circular Exome 40 | if (depth.indexOf(id) > -1) { 41 | return { 42 | $$exome_id: id, 43 | }; 44 | } 45 | 46 | const data = deepCloneStore({ ...value }, depth.concat(id)); 47 | 48 | return { 49 | $$exome_id: id, 50 | ...data, 51 | }; 52 | } 53 | 54 | if ( 55 | value.constructor !== Array && 56 | value.constructor !== Object && 57 | value.constructor !== Date 58 | ) { 59 | return { 60 | $$exome_class: value.constructor.name, 61 | }; 62 | } 63 | 64 | const output: Record = value.constructor() || {}; 65 | 66 | for (const key of Object.keys(value)) { 67 | output[key] = deepCloneStore(value[key], depth); 68 | } 69 | 70 | return output; 71 | } 72 | 73 | const getFullStore = (): any => { 74 | const output: Record = {}; 75 | 76 | for (const [key, map] of fullStore.entries()) { 77 | output[key] = Array.from(map.values()); 78 | } 79 | 80 | // Improve serializer with `__serializedType__` once https://github.com/zalmoxisus/redux-devtools-extension/issues/737 is resolved 81 | return deepCloneStore(output); 82 | }; 83 | 84 | /** 85 | * Subscribes to Redux DevTools. 86 | * https://chromewebstore.google.com/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd 87 | */ 88 | export const exomeReduxDevtools = ({ 89 | name, 90 | maxAge, 91 | actionsBlacklist, 92 | }: { 93 | name?: string; 94 | maxAge?: number; 95 | actionsBlacklist?: string; 96 | }): Middleware => { 97 | const devtoolName: string = "__REDUX_DEVTOOLS_EXTENSION__"; 98 | let extension: any; 99 | try { 100 | extension = 101 | (window as any)[devtoolName] || (window.top as any)[devtoolName]; 102 | } catch (e) {} 103 | 104 | if (!extension) { 105 | return () => {}; 106 | } 107 | 108 | const config: ReduxConfig = { 109 | name, 110 | maxAge, 111 | actionsBlacklist, 112 | }; 113 | 114 | const ReduxTool: Redux = extension.connect(config); 115 | 116 | ReduxTool.subscribe((message) => { 117 | if (message.type === "DISPATCH" && message.state) { 118 | // We'll just use json parse reviver function to update instances 119 | JSON.parse(message.state, (_, value) => { 120 | if ( 121 | typeof value === "object" && 122 | value !== null && 123 | "$$exome_id" in value 124 | ) { 125 | const { $$exome_id, ...restValue } = value; 126 | const [name] = $$exome_id.split("-"); 127 | const instance = fullStore.get(name)?.get($$exome_id); 128 | 129 | Object.assign(instance!, restValue); 130 | 131 | return instance; 132 | } 133 | 134 | return value; 135 | }); 136 | 137 | updateAll(); 138 | } 139 | }); 140 | 141 | ReduxTool.init(getFullStore()); 142 | 143 | return (instance, action, payload) => { 144 | const id = getExomeId(instance); 145 | const name: string = id.replace(/-.*$/, ""); 146 | const type = `[${name}] ${action}`; 147 | 148 | if (!name) { 149 | return; 150 | } 151 | 152 | if (!fullStore.has(name)) { 153 | fullStore.set(name, new Map()); 154 | } 155 | 156 | if (action === "NEW") { 157 | fullStore.get(name)?.set(getExomeId(instance), instance); 158 | ReduxTool.send({ type, payload: undefined }, getFullStore()); 159 | } 160 | 161 | return () => { 162 | let parsedPayload: any[] = []; 163 | 164 | try { 165 | parsedPayload = JSON.parse(JSON.stringify(payload)); 166 | } catch (e) {} 167 | 168 | ReduxTool.send({ type, payload: parsedPayload }, getFullStore()); 169 | }; 170 | }; 171 | }; 172 | -------------------------------------------------------------------------------- /src/utils.test.ts: -------------------------------------------------------------------------------- 1 | import proxyquire from "proxyquire"; 2 | import { suite } from "uvu"; 3 | import assert from "uvu/assert"; 4 | 5 | import { Exome, addMiddleware, getExomeId, update } from "./exome.ts"; 6 | import { middleware } from "./middleware.ts"; 7 | 8 | const { getActionStatus } = proxyquire 9 | .noCallThru() 10 | .load("./utils.ts", { 11 | exome: { 12 | Exome, 13 | getExomeId, 14 | addMiddleware, 15 | update, 16 | }, 17 | }); 18 | 19 | { 20 | const test = suite("getActionStatus"); 21 | 22 | test.before.each(() => { 23 | middleware.splice(0, 100); 24 | }); 25 | 26 | test("exports `getActionStatus`", () => { 27 | assert.ok(getActionStatus); 28 | }); 29 | 30 | test("that `getActionStatus` is function", () => { 31 | assert.instance(getActionStatus, Function); 32 | }); 33 | 34 | test("returns same instance if same store + action", () => { 35 | class TestStore extends Exome { 36 | public async run1() {} 37 | public async run2() {} 38 | } 39 | const test1 = new TestStore(); 40 | const test2 = new TestStore(); 41 | 42 | const test1run1_1 = getActionStatus(test1, "run1"); 43 | const test1run1_2 = getActionStatus(test1, "run1"); 44 | const test1run2_1 = getActionStatus(test1, "run2"); 45 | 46 | assert.ok(test1run1_1 === test1run1_2); 47 | assert.not(test1run1_1 === test1run2_1); 48 | 49 | const test2run1_1 = getActionStatus(test2, "run1"); 50 | 51 | assert.not(test1run1_1 === test2run1_1); 52 | }); 53 | 54 | test("returns same instance if not unsubscribed", () => { 55 | class TestStore extends Exome { 56 | public async run() {} 57 | } 58 | const test1 = new TestStore(); 59 | 60 | const test1run_1 = getActionStatus(test1, "run"); 61 | 62 | test1run_1.unsubscribe(); 63 | 64 | const test1run_2 = getActionStatus(test1, "run"); 65 | 66 | assert.not(test1run_1 === test1run_2); 67 | }); 68 | 69 | test("returns loading status", async () => { 70 | class TestStore extends Exome { 71 | public get status() { 72 | return getActionStatus(this, "run"); 73 | } 74 | public async run() {} 75 | } 76 | const test1 = new TestStore(); 77 | 78 | // biome-ignore lint/suspicious/noSelfCompare: it's a getter, not pointless! 79 | assert.ok(test1.status === test1.status); 80 | 81 | assert.snapshot( 82 | JSON.stringify(test1.status), 83 | `{"loading":false,"error":false}`, 84 | ); 85 | 86 | const promise = test1.run(); 87 | 88 | assert.snapshot( 89 | JSON.stringify(test1.status), 90 | `{"loading":true,"error":false}`, 91 | ); 92 | 93 | await promise; 94 | 95 | assert.snapshot( 96 | JSON.stringify(test1.status), 97 | `{"loading":false,"error":false}`, 98 | ); 99 | }); 100 | 101 | test("returns error status", async () => { 102 | class TestStore extends Exome { 103 | public get status() { 104 | return getActionStatus(this, "run"); 105 | } 106 | public async run() { 107 | throw new Error("Poop"); 108 | } 109 | } 110 | const test1 = new TestStore(); 111 | 112 | // biome-ignore lint/suspicious/noSelfCompare: it's a getter, not pointless! 113 | assert.ok(test1.status === test1.status); 114 | 115 | assert.snapshot( 116 | JSON.stringify(test1.status), 117 | `{"loading":false,"error":false}`, 118 | ); 119 | 120 | const promise = test1.run(); 121 | 122 | assert.snapshot( 123 | JSON.stringify(test1.status), 124 | `{"loading":true,"error":false}`, 125 | ); 126 | 127 | try { 128 | await promise; 129 | } catch (_) {} 130 | 131 | assert.snapshot( 132 | JSON.stringify(test1.status), 133 | `{"loading":false,"error":{}}`, 134 | ); 135 | assert.instance(test1.status.error, Error); 136 | assert.equal(String(test1.status.error), "Error: Poop"); 137 | }); 138 | 139 | test("returns response", async () => { 140 | class TestStore extends Exome { 141 | public get status() { 142 | return getActionStatus(this, "run"); 143 | } 144 | public async run() { 145 | return "Poop"; 146 | } 147 | } 148 | const test1 = new TestStore(); 149 | 150 | // biome-ignore lint/suspicious/noSelfCompare: it's a getter, not pointless! 151 | assert.ok(test1.status === test1.status); 152 | 153 | assert.snapshot( 154 | JSON.stringify(test1.status), 155 | `{"loading":false,"error":false}`, 156 | ); 157 | 158 | const promise = test1.run(); 159 | 160 | assert.snapshot( 161 | JSON.stringify(test1.status), 162 | `{"loading":true,"error":false}`, 163 | ); 164 | 165 | try { 166 | await promise; 167 | } catch (_) {} 168 | 169 | assert.snapshot( 170 | JSON.stringify(test1.status), 171 | `{"loading":false,"error":false,"response":"Poop"}`, 172 | ); 173 | }); 174 | 175 | test.run(); 176 | } 177 | -------------------------------------------------------------------------------- /src/utils/save-state.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from "uvu"; 2 | import assert from "uvu/assert"; 3 | 4 | import { Exome, exomeId } from "exome"; 5 | import { saveState } from "exome/state"; 6 | 7 | test("exports `saveState`", () => { 8 | assert.ok(saveState); 9 | }); 10 | 11 | test("that `saveState` is function", () => { 12 | assert.instance(saveState, Function); 13 | }); 14 | 15 | test('returns "null" if `null` is passed in', () => { 16 | const output = saveState(null as any); 17 | 18 | assert.snapshot(output, "null"); 19 | assert.not.throws(() => JSON.parse(output)); 20 | }); 21 | 22 | test('returns "" if `undefined` is passed in', () => { 23 | const output = saveState(undefined as any); 24 | 25 | assert.snapshot(output, "null"); 26 | assert.not.throws(() => JSON.parse(output)); 27 | }); 28 | 29 | test('returns "{}" if `Exome` instance is passed without exomeId', () => { 30 | const instance = new Exome(); 31 | (instance[exomeId] as any) = undefined; 32 | 33 | const output = saveState(instance); 34 | 35 | assert.snapshot(output, "{}"); 36 | assert.not.throws(() => JSON.parse(output)); 37 | }); 38 | 39 | test("returns correct snapshot with empty `Exome` instance", () => { 40 | const instance = new Exome(); 41 | instance[exomeId] = "foo"; 42 | 43 | const output = saveState(instance); 44 | 45 | assert.snapshot(output, '{"$$exome_id":"foo"}'); 46 | assert.not.throws(() => JSON.parse(output)); 47 | }); 48 | 49 | test("returns correct snapshot with filled `Exome` instance", () => { 50 | class Person extends Exome { 51 | public firstName = "John"; 52 | public lastName = "Wick"; 53 | } 54 | const instance = new Person(); 55 | instance[exomeId] = "foo"; 56 | 57 | const output = saveState(instance); 58 | 59 | assert.snapshot( 60 | output, 61 | '{"$$exome_id":"foo","firstName":"John","lastName":"Wick"}', 62 | ); 63 | assert.not.throws(() => JSON.parse(output)); 64 | }); 65 | 66 | test("returns correct snapshot with nested `Exome` instance", () => { 67 | class Interest extends Exome { 68 | constructor(public type: string) { 69 | super(); 70 | } 71 | } 72 | 73 | const interestSkating = new Interest("Skating"); 74 | interestSkating[exomeId] = "skating-123"; 75 | 76 | const interestHockey = new Interest("Hockey"); 77 | interestHockey[exomeId] = "hockey-123"; 78 | 79 | class Person extends Exome { 80 | constructor( 81 | public name: string, 82 | public interests: Interest[] = [], 83 | ) { 84 | super(); 85 | } 86 | } 87 | 88 | const personJohn = new Person("John", [interestHockey, interestSkating]); 89 | personJohn[exomeId] = "john-123"; 90 | 91 | const personJane = new Person("Jane", [interestSkating]); 92 | personJane[exomeId] = "jane-123"; 93 | 94 | class Store extends Exome { 95 | constructor(public persons: Person[]) { 96 | super(); 97 | } 98 | } 99 | 100 | const store = new Store([personJohn, personJane]); 101 | store[exomeId] = "store-123"; 102 | 103 | const output = saveState(store, true); 104 | 105 | assert.snapshot( 106 | output, 107 | `{ 108 | "$$exome_id": "store-123", 109 | "persons": [ 110 | { 111 | "$$exome_id": "john-123", 112 | "name": "John", 113 | "interests": [ 114 | { 115 | "$$exome_id": "hockey-123", 116 | "type": "Hockey" 117 | }, 118 | { 119 | "$$exome_id": "skating-123", 120 | "type": "Skating" 121 | } 122 | ] 123 | }, 124 | { 125 | "$$exome_id": "jane-123", 126 | "name": "Jane", 127 | "interests": [ 128 | { 129 | "$$exome_id": "skating-123" 130 | } 131 | ] 132 | } 133 | ] 134 | }`, 135 | ); 136 | assert.not.throws(() => JSON.parse(output)); 137 | }); 138 | 139 | test("returns correct snapshot for circular `Exome` instance", () => { 140 | class Person extends Exome { 141 | constructor( 142 | public name: string, 143 | public friends: Person[], 144 | ) { 145 | super(); 146 | } 147 | } 148 | 149 | class Store extends Exome { 150 | constructor(public persons: Person[]) { 151 | super(); 152 | } 153 | } 154 | 155 | const personJohn = new Person("John", []); 156 | personJohn[exomeId] = "john-123"; 157 | personJohn.friends = [personJohn]; 158 | 159 | const store = new Store([personJohn]); 160 | store[exomeId] = "store-123"; 161 | 162 | const output = saveState(store, true); 163 | 164 | assert.snapshot( 165 | output, 166 | `{ 167 | "$$exome_id": "store-123", 168 | "persons": [ 169 | { 170 | "$$exome_id": "john-123", 171 | "name": "John", 172 | "friends": [ 173 | { 174 | "$$exome_id": "john-123" 175 | } 176 | ] 177 | } 178 | ] 179 | }`, 180 | ); 181 | assert.not.throws(() => JSON.parse(output)); 182 | }); 183 | 184 | test.run(); 185 | -------------------------------------------------------------------------------- /logo/logo-title-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /logo/logo-title-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/middleware.test.ts: -------------------------------------------------------------------------------- 1 | import { fake } from "sinon"; 2 | import { test } from "uvu"; 3 | import assert from "uvu/assert"; 4 | 5 | import { Exome } from "./exome"; 6 | import { addMiddleware, runMiddleware } from "./middleware"; 7 | 8 | test("exports `addMiddleware`", () => { 9 | assert.ok(addMiddleware); 10 | }); 11 | 12 | test("that `addMiddleware` is function", () => { 13 | assert.instance(addMiddleware, Function); 14 | }); 15 | 16 | test("exports `runMiddleware`", () => { 17 | assert.ok(runMiddleware); 18 | }); 19 | 20 | test("that `runMiddleware` is function", () => { 21 | assert.instance(runMiddleware, Function); 22 | }); 23 | 24 | test("adds middleware without issues via `addMiddleware`", () => { 25 | const middleware = fake(); 26 | 27 | assert.not.throws(() => addMiddleware(middleware)); 28 | }); 29 | 30 | test("runs added middleware via `runMiddleware`", () => { 31 | const instance = new Exome(); 32 | const middleware1 = fake(); 33 | const middleware2 = fake(); 34 | 35 | addMiddleware(middleware1); 36 | addMiddleware(middleware2); 37 | 38 | runMiddleware(instance, "actionName", []); 39 | 40 | assert.equal(middleware1.callCount, 1); 41 | assert.equal(middleware1.args[0][0], instance); 42 | assert.equal(middleware1.args[0][1], "actionName"); 43 | assert.equal(middleware1.args[0][2], []); 44 | 45 | assert.equal(middleware2.callCount, 1); 46 | assert.equal(middleware2.args[0][0], instance); 47 | assert.equal(middleware2.args[0][1], "actionName"); 48 | assert.equal(middleware2.args[0][2], []); 49 | }); 50 | 51 | test("runs middleware unsubscribe method", () => { 52 | const instance = new Exome(); 53 | const unsubscribe = fake(); 54 | const middleware: any = fake.returns(unsubscribe); 55 | 56 | addMiddleware(middleware); 57 | 58 | const after = runMiddleware(instance, "actionName", []); 59 | 60 | assert.equal(middleware.callCount, 1); 61 | assert.equal(middleware.args[0][0], instance); 62 | assert.equal(middleware.args[0][1], "actionName"); 63 | assert.equal(middleware.args[0][2], []); 64 | 65 | assert.equal(unsubscribe.callCount, 0); 66 | 67 | after(); 68 | 69 | assert.equal(unsubscribe.callCount, 1); 70 | assert.equal(unsubscribe.args[0], [undefined, undefined]); 71 | }); 72 | 73 | test("runs middleware unsubscribe method with error", () => { 74 | const instance = new Exome(); 75 | const unsubscribe = fake(); 76 | const middleware: any = fake.returns(unsubscribe); 77 | 78 | addMiddleware(middleware); 79 | 80 | const after = runMiddleware(instance, "actionName", []); 81 | 82 | assert.equal(middleware.callCount, 1); 83 | assert.equal(middleware.args[0][0], instance); 84 | assert.equal(middleware.args[0][1], "actionName"); 85 | assert.equal(middleware.args[0][2], []); 86 | 87 | assert.equal(unsubscribe.callCount, 0); 88 | 89 | after(new Error("test")); 90 | 91 | assert.equal(unsubscribe.callCount, 1); 92 | assert.equal(unsubscribe.args[0], [new Error("test"), undefined]); 93 | }); 94 | 95 | test("runs middleware unsubscribe method with response", () => { 96 | const instance = new Exome(); 97 | const unsubscribe = fake(); 98 | const middleware: any = fake.returns(unsubscribe); 99 | 100 | addMiddleware(middleware); 101 | 102 | const after = runMiddleware(instance, "actionName", []); 103 | 104 | assert.equal(middleware.callCount, 1); 105 | assert.equal(middleware.args[0][0], instance); 106 | assert.equal(middleware.args[0][1], "actionName"); 107 | assert.equal(middleware.args[0][2], []); 108 | 109 | assert.equal(unsubscribe.callCount, 0); 110 | 111 | after(undefined, "response"); 112 | 113 | assert.equal(unsubscribe.callCount, 1); 114 | assert.equal(unsubscribe.args[0], [undefined, "response"]); 115 | }); 116 | 117 | test("removes middleware correctly", () => { 118 | const instance = new Exome(); 119 | const middleware = fake(); 120 | 121 | const unsubscribe = addMiddleware(middleware); 122 | 123 | runMiddleware(instance, "actionName", []); 124 | 125 | assert.equal(middleware.callCount, 1); 126 | assert.equal(middleware.args[0], [instance, "actionName", []]); 127 | 128 | runMiddleware(instance, "actionName", []); 129 | 130 | assert.equal(middleware.callCount, 2); 131 | assert.equal(middleware.args[1], [instance, "actionName", []]); 132 | 133 | unsubscribe(); 134 | runMiddleware(instance, "actionName", []); 135 | 136 | assert.equal(middleware.callCount, 2); 137 | }); 138 | 139 | test("removes multiple middleware correctly", () => { 140 | const instance = new Exome(); 141 | const middleware1 = fake(); 142 | const middleware2 = fake(); 143 | 144 | const unsubscribe1 = addMiddleware(middleware1); 145 | const unsubscribe2 = addMiddleware(middleware2); 146 | 147 | runMiddleware(instance, "actionName", []); 148 | 149 | assert.equal(middleware1.callCount, 1); 150 | assert.equal(middleware1.args[0], [instance, "actionName", []]); 151 | assert.equal(middleware2.callCount, 1); 152 | assert.equal(middleware2.args[0], [instance, "actionName", []]); 153 | 154 | runMiddleware(instance, "actionName", []); 155 | 156 | assert.equal(middleware1.callCount, 2); 157 | assert.equal(middleware1.args[1], [instance, "actionName", []]); 158 | assert.equal(middleware2.callCount, 2); 159 | assert.equal(middleware2.args[1], [instance, "actionName", []]); 160 | 161 | unsubscribe1(); 162 | unsubscribe2(); 163 | runMiddleware(instance, "actionName", []); 164 | 165 | assert.equal(middleware1.callCount, 2); 166 | assert.equal(middleware2.callCount, 2); 167 | }); 168 | 169 | test.run(); 170 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 2.8.1 4 | 5 | ### Bugfix 6 | * Detect thenable (not necessarily a Promise instance) in actions. 7 | 8 | ## 2.8.0 9 | 10 | ### Feature 11 | * Allow to read action response/return value from `getActionStatus` function. 12 | 13 | ## 2.7.0 14 | 15 | ### Feature 16 | * Improve types for `onAction` function. 17 | 18 | ## 2.6.2 19 | 20 | ### Other 21 | * Allow `null` and `undefined` in `useStore` function. 22 | 23 | ## 2.6.1 24 | 25 | ### Other 26 | * Updates typedoc. 27 | 28 | ## 2.6.0 29 | 30 | ### Feature 31 | * Adds action response data to `addMiddleware`, `runMiddleware` and `onAction`. 32 | 33 | ## 2.5.0 34 | 35 | ### Feature 36 | * Allows deep inheritance. 37 | 38 | ## 2.4.4 39 | 40 | ### Other 41 | * Upgrades esbuild. 42 | * Upgrades biome. 43 | 44 | ## 2.4.3 45 | 46 | ### Other 47 | * Makes types more explicit. 48 | * Publish to jsr. 49 | 50 | ## 2.4.2 51 | 52 | ### Other 53 | * Pass action error to devtools. 54 | 55 | ## 2.4.1 56 | 57 | ### Bugfix 58 | * Properly re-throw errors from async actions. 59 | 60 | ## 2.4.0 61 | 62 | ### Feature 63 | * Adds `getActionStatus` to `exome/utils`. 64 | 65 | ## 2.3.3 66 | 67 | ### Bugfix 68 | * Properly handle errors in actions. 69 | * Middleware now also returns error from actions. 70 | 71 | ## 2.3.2 72 | 73 | ### Bugfix 74 | * Adds version export to `unstableExomeDevtools`. 75 | 76 | ## 2.3.1 77 | 78 | ### Other 79 | * Adds `unstableExomeDevtools` method to `exome/devtools` package. 80 | 81 | ## 2.3.0 82 | 83 | ### Feature 84 | * Add `LOAD_STATE` middleware action after state was prefilled via loadState. 85 | 86 | ## 2.2.0 87 | 88 | ### Feature 89 | * Increase exome id length by 2 characters to lessen the birthday paradox. 90 | * Use "try finally" for running "NEW" actions instead of "Promise.resolve()". 91 | 92 | ## 2.1.0 93 | 94 | ### Feature 95 | * Adds support for Solid.js. 96 | * Adds support for Angular signals. 97 | 98 | ## 2.0.4 99 | 100 | ### Bugfix 101 | * Print circular store references in jest snapshots. 102 | 103 | ## 2.0.3 104 | 105 | ### Bugfix 106 | * Fixes jest snapshot serializer depth. 107 | 108 | ## 2.0.2 109 | 110 | ### Bugfix 111 | * Fixes `subscribe` method where it did not send store instance as argument. 112 | 113 | ## 2.0.1 114 | 115 | ### Bugfix 116 | * Fixes vue integration of `useStore`. 117 | 118 | ## 2.0.0 119 | 120 | ### Breaking 121 | * Reorganizes imports; 122 | * Removes `updateMap`; 123 | * Replaces `updateView` with `updateAll`; 124 | * Replaces `exomeDevtools` with `exomeReduxDevtools`. 125 | 126 | Please read the [migration guide](/MIGRATION-1-to-2.md) to ease the upgrade process. 127 | 128 | ### Migration guide 129 | 130 | v2 includes some breaking changes around subscriptions. It better reorganizes files and imports. 131 | 132 | Here are changes that need to be made: 133 | 134 | 1. `subscribe` is no longer in a separate import: 135 | 136 | ```diff 137 | -import { subscribe } from "exome/subscribe"; 138 | +import { subscribe } from "exome"; 139 | ``` 140 | 141 | 2. `saveState`, `loadState` and `registerLoadable` is no longer part of root import: 142 | 143 | ```diff 144 | -import { saveState, loadState, registerLoadable } from "exome"; 145 | +import { saveState, loadState, registerLoadable } from "exome/state"; 146 | ``` 147 | 148 | 3. `GhostExome` is no longer part of root import: 149 | 150 | ```diff 151 | -import { GhostExome } from "exome"; 152 | +import { GhostExome } from "exome/ghost"; 153 | ``` 154 | 155 | 4. `updateMap` is no longer exposed (use `subscribe`, `update` and `updateAll` to listen to changes or trigger them): 156 | 157 | 5. `updateView` is renamed to `updateAll`: 158 | 159 | ```diff 160 | -import { updateView } from "exome"; 161 | +import { updateAll } from "exome"; 162 | ``` 163 | 164 | 6. `exomeDevtools` is renamed to `exomeReduxDevtools`: 165 | 166 | ```diff 167 | -import { exomeDevtools } from "exome/devtools"; 168 | +import { exomeReduxDevtools } from "exome/devtools"; 169 | ``` 170 | 171 | ## 1.5.6 172 | 173 | ### Other 174 | * Published to npm with new logo. 175 | 176 | ## 1.5.5 177 | 178 | ### Other 179 | * Published to npm with provenance. 180 | 181 | ## 1.5.4 182 | 183 | ### Bugfix 184 | * Removes `peerDependencies` from package.json. 185 | 186 | ## 1.5.3 187 | 188 | ### Bugfixes 189 | * Updates documentation; 190 | * Cleans up published package.json file. 191 | 192 | ## 1.5.0 193 | 194 | ### Features 195 | * Moves exported import files to `.mjs` file format. 196 | 197 | ## 1.4.0 198 | 199 | ### Features 200 | * Adds support for Svelte. 201 | 202 | ## 1.3.0 203 | 204 | ### Features 205 | * Performance improvements; 206 | * Gets rid of Proxy usage as it was just an overhead without real benefits. 207 | 208 | ## 1.2.0 209 | 210 | ### Features 211 | * Adds RXJS Observable support. 212 | 213 | ## 1.1.0 214 | 215 | ### Features 216 | * Adds Deno support. 217 | 218 | ## 1.0.3 219 | 220 | ### Bugfixes 221 | * Fixes broken redux devtools url. 222 | 223 | ## 1.0.2 224 | 225 | ### Bugfixes 226 | * Fixes issue where getter get called before ready. 227 | 228 | ## 1.0.1 229 | 230 | ### Bugfixes 231 | * Fixes rxjs compatibility issue when using `BehaviorSubject` inside `Exome`. 232 | 233 | ## 1.0.0 234 | 235 | ### Stable release 236 | No actual changes as it's proven to be stable fo v1. 237 | 238 | ## 0.16.0 239 | 240 | ### Feature 241 | * Adds `lit` support. 242 | 243 | Added new ReactiveController named `StoreController` as part of lit v2.0. 244 | 245 | ## 0.15.0 246 | 247 | ### Feature 248 | * Arrow functions no longer trigger actions. 249 | 250 | This was previously wrong as we only should trigger actions for prototype methods. It is useful to define arrow method to GET some data and that should NOT trigger action and re-render. 251 | 252 | ## 0.14.0 253 | 254 | ### Feature 255 | * Adds experimental `afterLoadState` method that triggers callback whenever Exome data was loaded via `loadState`. 256 | 257 | ## 0.13.0 258 | 259 | ### Feature 260 | * Adds new `onAction` method that triggers callback whenever specific action is called. 261 | 262 | ## 0.12.4 263 | 264 | ### Bugfixes 265 | * Fixes `loadState` inability to load circular Exome instances. 266 | 267 | ## 0.12.3 268 | 269 | ### Bugfixes 270 | * Fixes `saveState` snapshot of circular Exome instances. 271 | 272 | ## 0.12.1 273 | 274 | ### Bugfixes 275 | * `saveState` and `loadState` now works with minified class names; 276 | * Issue with state type but warning about store type in load-state ([#8](https://github.com/Marcisbee/exome/pull/8)). 277 | 278 | ## 0.12.0 279 | 280 | ### Breaking changes 281 | 282 | * Adds `registerLoadable` method that gathers all available Exomes that can be registered; 283 | * Removes 3rd argument for `loadState` method; 284 | 285 | ```diff 286 | - loadState(target, state, { Person, Dog }) 287 | + registerLoadable({ Person, Dog }) 288 | + loadState(target, state) 289 | ``` 290 | 291 | ## 0.11.0 292 | 293 | * Adds `subscribe` method that allows to listen for changes in particular Exome instance; 294 | 295 | ## 0.10.1 296 | 297 | * Fixes jest serializer output for `GhostExome`; 298 | 299 | ## 0.10.0 300 | 301 | * Adds `GhostExome` class; 302 | 303 | It is accepted as Exome instance, but will not update or call middleware. 304 | 305 | ## 0.9.2 306 | 307 | * Fixes type declaration. 308 | 309 | ## 0.9.1 310 | 311 | * Fixes vue export; 312 | * Adds `setExomeId` method. 313 | 314 | ## 0.9.0 315 | 316 | * Adds Vue support. 317 | 318 | Added `useStore` hook for Vue 3 composition api. 319 | -------------------------------------------------------------------------------- /logo/LICENSE-logo: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | 14 | Statement of Purpose 15 | 16 | The laws of most jurisdictions throughout the world automatically confer 17 | exclusive Copyright and Related Rights (defined below) upon the creator 18 | and subsequent owner(s) (each and all, an "owner") of an original work of 19 | authorship and/or a database (each, a "Work"). 20 | 21 | Certain owners wish to permanently relinquish those rights to a Work for 22 | the purpose of contributing to a commons of creative, cultural and 23 | scientific works ("Commons") that the public can reliably and without fear 24 | of later claims of infringement build upon, modify, incorporate in other 25 | works, reuse and redistribute as freely as possible in any form whatsoever 26 | and for any purposes, including without limitation commercial purposes. 27 | These owners may contribute to the Commons to promote the ideal of a free 28 | culture and the further production of creative, cultural and scientific 29 | works, or to gain reputation or greater distribution for their Work in 30 | part through the use and efforts of others. 31 | 32 | For these and/or other purposes and motivations, and without any 33 | expectation of additional consideration or compensation, the person 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 | is an owner of Copyright and Related Rights in the Work, voluntarily 36 | elects to apply CC0 to the Work and publicly distribute the Work under its 37 | terms, with knowledge of his or her Copyright and Related Rights in the 38 | Work and the meaning and intended legal effect of CC0 on those rights. 39 | 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be 41 | protected by copyright and related or neighboring rights ("Copyright and 42 | Related Rights"). Copyright and Related Rights include, but are not 43 | limited to, the following: 44 | 45 | i. the right to reproduce, adapt, distribute, perform, display, 46 | communicate, and translate a Work; 47 | ii. moral rights retained by the original author(s) and/or performer(s); 48 | iii. publicity and privacy rights pertaining to a person's image or 49 | likeness depicted in a Work; 50 | iv. rights protecting against unfair competition in regards to a Work, 51 | subject to the limitations in paragraph 4(a), below; 52 | v. rights protecting the extraction, dissemination, use and reuse of data 53 | in a Work; 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal 56 | protection of databases, and under any national implementation 57 | thereof, including any amended or successor version of such 58 | directive); and 59 | vii. other similar, equivalent or corresponding rights throughout the 60 | world based on applicable law or treaty, and any national 61 | implementations thereof. 62 | 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, 65 | irrevocably and unconditionally waives, abandons, and surrenders all of 66 | Affirmer's Copyright and Related Rights and associated claims and causes 67 | of action, whether now known or unknown (including existing as well as 68 | future claims and causes of action), in the Work (i) in all territories 69 | worldwide, (ii) for the maximum duration provided by applicable law or 70 | treaty (including future time extensions), (iii) in any current or future 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, 72 | including without limitation commercial, advertising or promotional 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 | member of the public at large and to the detriment of Affirmer's heirs and 75 | successors, fully intending that such Waiver shall not be subject to 76 | revocation, rescission, cancellation, termination, or any other legal or 77 | equitable action to disrupt the quiet enjoyment of the Work by the public 78 | as contemplated by Affirmer's express Statement of Purpose. 79 | 80 | 3. Public License Fallback. Should any part of the Waiver for any reason 81 | be judged legally invalid or ineffective under applicable law, then the 82 | Waiver shall be preserved to the maximum extent permitted taking into 83 | account Affirmer's express Statement of Purpose. In addition, to the 84 | extent the Waiver is so judged Affirmer hereby grants to each affected 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 | maximum duration provided by applicable law or treaty (including future 89 | time extensions), (iii) in any current or future medium and for any number 90 | of copies, and (iv) for any purpose whatsoever, including without 91 | limitation commercial, advertising or promotional purposes (the 92 | "License"). The License shall be deemed effective as of the date CC0 was 93 | applied by Affirmer to the Work. Should any part of the License for any 94 | reason be judged legally invalid or ineffective under applicable law, such 95 | partial invalidity or ineffectiveness shall not invalidate the remainder 96 | of the License, and in such case Affirmer hereby affirms that he or she 97 | will not (i) exercise any of his or her remaining Copyright and Related 98 | Rights in the Work or (ii) assert any associated claims and causes of 99 | action with respect to the Work, in either case contrary to Affirmer's 100 | express Statement of Purpose. 101 | 102 | 4. Limitations and Disclaimers. 103 | 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 | surrendered, licensed or otherwise affected by this document. 106 | b. Affirmer offers the Work as-is and makes no representations or 107 | warranties of any kind concerning the Work, express, implied, 108 | statutory or otherwise, including without limitation warranties of 109 | title, merchantability, fitness for a particular purpose, non 110 | infringement, or the absence of latent or other defects, accuracy, or 111 | the present or absence of errors, whether or not discoverable, all to 112 | the greatest extent permissible under applicable law. 113 | c. Affirmer disclaims responsibility for clearing rights of other persons 114 | that may apply to the Work or any use thereof, including without 115 | limitation any person's Copyright and Related Rights in the Work. 116 | Further, Affirmer disclaims responsibility for obtaining any necessary 117 | consents, permissions or other rights required for any use of the 118 | Work. 119 | d. Affirmer understands and acknowledges that Creative Commons is not a 120 | party to this document and has no duty or obligation with respect to 121 | this CC0 or use of the Work. 122 | -------------------------------------------------------------------------------- /src/utils/load-state.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from "uvu"; 2 | import assert from "uvu/assert"; 3 | 4 | import { Exome, exomeId } from "exome"; 5 | import { loadState, registerLoadable } from "exome/state"; 6 | 7 | test("exports `loadState`", () => { 8 | assert.ok(loadState); 9 | }); 10 | 11 | test("that `loadState` is function", () => { 12 | assert.instance(loadState, Function); 13 | }); 14 | 15 | test("throws error if `undefined` is passed in", () => { 16 | const target = new Exome(); 17 | 18 | assert.throws(() => { 19 | loadState(target, undefined as any); 20 | }); 21 | }); 22 | 23 | test("throws error if `null` is passed in", () => { 24 | const target = new Exome(); 25 | 26 | assert.throws(() => { 27 | loadState(target, null as any); 28 | }); 29 | }); 30 | 31 | test('throws error if "null" is passed in', () => { 32 | const target = new Exome(); 33 | 34 | assert.throws(() => { 35 | loadState(target, "null"); 36 | }); 37 | }); 38 | 39 | test('throws error if "{}" is passed in', () => { 40 | const target = new Exome(); 41 | 42 | assert.throws(() => { 43 | loadState(target, "{}"); 44 | }); 45 | }); 46 | 47 | test('throws error if "{}" is passed in', () => { 48 | const target = new Exome(); 49 | 50 | assert.throws(() => { 51 | loadState(target, "{}"); 52 | }); 53 | }); 54 | 55 | test("doesn't modify root id of Exome instance", () => { 56 | const target = new Exome(); 57 | const state = JSON.stringify({ 58 | $$exome_id: "Exome-1", 59 | }); 60 | 61 | loadState(target, state); 62 | 63 | assert.not.equal(target[exomeId], "Exome-1"); 64 | }); 65 | 66 | test("assigns simple property to Exome instance", () => { 67 | class Person extends Exome { 68 | public name?: string; 69 | } 70 | const target = new Person(); 71 | const state = JSON.stringify({ 72 | $$exome_id: "Person-1", 73 | name: "John", 74 | }); 75 | 76 | loadState(target, state); 77 | 78 | assert.equal(target.name, "John"); 79 | }); 80 | 81 | test("throws error if class is missing", () => { 82 | class Person extends Exome { 83 | public name?: string; 84 | public friends: Person[] = []; 85 | } 86 | const target = new Person(); 87 | const state = JSON.stringify({ 88 | $$exome_id: "Person-1", 89 | name: "John", 90 | friends: [ 91 | { 92 | $$exome_id: "Person-2", 93 | name: "Jane", 94 | }, 95 | ], 96 | }); 97 | 98 | assert.throws(() => loadState(target, state)); 99 | }); 100 | 101 | test("assigns nested property to Exome instance", () => { 102 | class Person extends Exome { 103 | public name?: string; 104 | public friends: Person[] = []; 105 | } 106 | const target = new Person(); 107 | const state = JSON.stringify({ 108 | $$exome_id: "Person-1", 109 | name: "John", 110 | friends: [ 111 | { 112 | $$exome_id: "Person-2", 113 | name: "Jane", 114 | }, 115 | ], 116 | }); 117 | 118 | registerLoadable({ 119 | Person, 120 | }); 121 | 122 | loadState(target, state); 123 | 124 | assert.equal(target.name, "John"); 125 | assert.equal(target.friends.length, 1); 126 | assert.instance(target.friends[0], Person); 127 | assert.equal(target.friends[0].name, "Jane"); 128 | assert.equal(target.friends[0].friends, []); 129 | assert.equal(target.friends[0][exomeId], "Person-2"); 130 | }); 131 | 132 | test("assigns circular property to Exome instance", () => { 133 | class Dog extends Exome { 134 | public name?: string; 135 | } 136 | class Person extends Exome { 137 | public name?: string; 138 | public dogs: Dog[] = []; 139 | } 140 | class Store extends Exome { 141 | public persons: Person[] = []; 142 | } 143 | const target = new Store(); 144 | const state = JSON.stringify({ 145 | $$exome_id: "Store-1", 146 | persons: [ 147 | { 148 | $$exome_id: "Person-1", 149 | name: "John", 150 | dogs: [ 151 | { 152 | $$exome_id: "Dog-1", 153 | name: "Andy", 154 | }, 155 | { 156 | $$exome_id: "Dog-2", 157 | name: "Buttons", 158 | }, 159 | ], 160 | }, 161 | { 162 | $$exome_id: "Person-2", 163 | name: "Jane", 164 | dogs: [ 165 | { 166 | $$exome_id: "Dog-1", 167 | name: "Andy", 168 | }, 169 | ], 170 | }, 171 | ], 172 | }); 173 | 174 | registerLoadable({ 175 | Person, 176 | Dog, 177 | }); 178 | 179 | loadState(target, state); 180 | 181 | assert.equal(target.persons.length, 2); 182 | assert.instance(target.persons[0], Person); 183 | assert.equal(target.persons[0].name, "John"); 184 | 185 | assert.equal(target.persons[0].dogs.length, 2); 186 | assert.instance(target.persons[0].dogs[0], Dog); 187 | assert.equal(target.persons[0].dogs[0].name, "Andy"); 188 | 189 | assert.instance(target.persons[0].dogs[1], Dog); 190 | assert.equal(target.persons[0].dogs[1].name, "Buttons"); 191 | 192 | assert.instance(target.persons[1], Person); 193 | assert.equal(target.persons[1].name, "Jane"); 194 | 195 | assert.equal(target.persons[1].dogs.length, 1); 196 | assert.instance(target.persons[1].dogs[0], Dog); 197 | assert.equal(target.persons[1].dogs[0].name, "Andy"); 198 | 199 | assert.is(target.persons[0].dogs[0] === target.persons[1].dogs[0], true); 200 | }); 201 | 202 | test("throws error if Exome instance cannot be constructed", () => { 203 | class Person extends Exome { 204 | public friends: Person[] = []; 205 | 206 | constructor(public name: string) { 207 | super(); 208 | 209 | if (!name) { 210 | throw new Error("`name` must be defined"); 211 | } 212 | } 213 | } 214 | const target = new Person("Jeff"); 215 | const state = JSON.stringify({ 216 | $$exome_id: "Person-1", 217 | name: "John", 218 | friends: [ 219 | { 220 | $$exome_id: "Person-2", 221 | name: "Phil", 222 | }, 223 | ], 224 | }); 225 | 226 | registerLoadable({ 227 | Person, 228 | }); 229 | 230 | assert.throws(() => loadState(target, state)); 231 | }); 232 | 233 | test("creates proper instances with minified class names", () => { 234 | class P extends Exome { 235 | public friends: P[] = []; 236 | 237 | constructor(public name: string) { 238 | super(); 239 | } 240 | } 241 | const target = new P("Jeff"); 242 | const state = JSON.stringify({ 243 | $$exome_id: "Person-1", 244 | name: "John", 245 | friends: [ 246 | { 247 | $$exome_id: "Person-2", 248 | name: "Phil", 249 | }, 250 | ], 251 | }); 252 | 253 | registerLoadable({ 254 | Person: P, 255 | }); 256 | 257 | loadState(target, state); 258 | 259 | assert.equal(target.name, "John"); 260 | 261 | assert.equal(target.friends.length, 1); 262 | assert.instance(target.friends[0], P); 263 | assert.equal(target.friends[0].name, "Phil"); 264 | }); 265 | 266 | test("creates proper instances with circular state", () => { 267 | class Person extends Exome { 268 | constructor( 269 | public name: string, 270 | public friends: Person[], 271 | ) { 272 | super(); 273 | } 274 | } 275 | 276 | class Store extends Exome { 277 | constructor(public persons: Person[]) { 278 | super(); 279 | } 280 | } 281 | 282 | const target = new Store([]); 283 | 284 | const state = JSON.stringify({ 285 | $$exome_id: "Store-123", 286 | persons: [ 287 | { 288 | $$exome_id: "Person-123", 289 | name: "John", 290 | friends: [ 291 | { 292 | $$exome_id: "Person-123", 293 | }, 294 | ], 295 | }, 296 | ], 297 | }); 298 | 299 | registerLoadable({ Person, Store }); 300 | 301 | loadState(target, state); 302 | 303 | assert.equal(target.persons.length, 1); 304 | assert.equal(target.persons[0].name, "John"); 305 | assert.equal(target.persons[0].friends.length, 1); 306 | assert.is(target.persons[0].friends[0], target.persons[0]); 307 | }); 308 | 309 | test.run(); 310 | -------------------------------------------------------------------------------- /src/devtools-exome.ts: -------------------------------------------------------------------------------- 1 | import { Exome, type Middleware, getExomeId, subscribe } from "exome"; 2 | 3 | // @ts-ignore 4 | import packageJson from "../package.json" with { type: "json" }; 5 | 6 | export interface DevtoolsExtensionInterface { 7 | connect(config: { 8 | name: string; 9 | maxAge?: number; 10 | details: { 11 | version: string; 12 | }; 13 | }): DevtoolsExtensionConnectionInterface; 14 | } 15 | 16 | export interface DevtoolsExtensionConnectionInterface { 17 | disconnect(): void; 18 | 19 | send(data: { 20 | event: "update"; 21 | type: "all"; 22 | payload: { actions: Action[]; states: [string, any][] }; 23 | }): void; 24 | send(data: { event: "update"; type: "action"; payload: Action }): void; 25 | send(data: { 26 | event: "update"; 27 | type: "state"; 28 | payload: [string, any] | [string, any, string]; 29 | }): void; 30 | 31 | send(data: { event: "send"; type: "actions"; payload: Action[] }): void; 32 | send(data: { event: "send"; type: "action"; payload: Action }): void; 33 | send(data: { event: "send"; type: "states"; payload: [string, any][] }): void; 34 | send(data: { event: "send"; type: "state"; payload: [string, any] }): void; 35 | 36 | subscribe(cb: (data: { type: "sync" }) => void): () => void; 37 | } 38 | 39 | export interface ExomeDevtoolsConfig { 40 | name: string; 41 | maxAge?: number; 42 | ignoreListActions?: string[]; 43 | ignoreListStores?: string[]; 44 | } 45 | 46 | const fullStore: Map> = new Map(); 47 | const fullActions: Action[] = []; 48 | 49 | const descriptor = Object.getOwnPropertyDescriptor; 50 | 51 | /** 52 | * Subscribes to Exome Developer Tools. 53 | * https://chromewebstore.google.com/detail/exome-developer-tools/pcanmpamoedhpfpbjajlkpicbikbnhdg 54 | */ 55 | export const exomeDevtools = ({ 56 | name = "Exome", 57 | maxAge = 20, 58 | ignoreListActions = [], 59 | ignoreListStores = [], 60 | }: ExomeDevtoolsConfig): Middleware => { 61 | const devtoolName: string = "__EXOME_DEVTOOLS_EXTENSION__"; 62 | let extension: DevtoolsExtensionInterface | undefined; 63 | try { 64 | extension = 65 | (window as any)[devtoolName] || (window.top as any)[devtoolName]; 66 | } catch (_e) {} 67 | 68 | if (!extension) { 69 | return () => {}; 70 | } 71 | 72 | let depth = 0; 73 | const details = { 74 | version: packageJson.version, 75 | }; 76 | 77 | const connection = extension.connect({ name, maxAge, details }); 78 | 79 | window.addEventListener("beforeunload", connection.disconnect, { 80 | once: true, 81 | }); 82 | 83 | // Return requested data by 84 | connection.subscribe(({ type }) => { 85 | if (type === "sync") { 86 | connection.send({ 87 | event: "update", 88 | type: "all", 89 | payload: { 90 | actions: fullActions, 91 | states: [...fullStore].flatMap(([_name, map]) => 92 | [...map].map(([id, instance]) => [id, exomeToJson(instance)]), 93 | ) as any, 94 | }, 95 | }); 96 | } 97 | }); 98 | 99 | return (instance, name, payload) => { 100 | const storeId = getExomeId(instance); 101 | const storeName = storeId.replace(/-[a-z0-9]+$/gi, ""); 102 | 103 | if (ignoreListStores.indexOf(storeName) > -1) { 104 | return; 105 | } 106 | 107 | if (!fullStore.has(storeName)) { 108 | fullStore.set(storeName, new Map()); 109 | } 110 | 111 | if (name === "NEW") { 112 | fullStore.get(storeName)?.set(storeId, instance); 113 | connection.send({ 114 | event: "send", 115 | type: "state", 116 | payload: [storeId, exomeToJson(instance)], 117 | }); 118 | return () => { 119 | connection.send({ 120 | event: "update", 121 | type: "state", 122 | payload: [storeId, exomeToJson(instance), getExomeId(instance)], 123 | }); 124 | 125 | subscribe(instance, (instance) => { 126 | connection.send({ 127 | event: "update", 128 | type: "state", 129 | payload: [storeId, exomeToJson(instance), getExomeId(instance)], 130 | }); 131 | }); 132 | }; 133 | } 134 | 135 | if (name === "LOAD_STATE") { 136 | return () => { 137 | connection.send({ 138 | event: "update", 139 | type: "state", 140 | payload: [storeId, exomeToJson(instance), getExomeId(instance)], 141 | }); 142 | }; 143 | } 144 | 145 | if (ignoreListActions.indexOf(`${storeName}.${name}`) > -1) { 146 | return; 147 | } 148 | 149 | const before = exomeToJson(instance); 150 | const id = String(Math.random()); 151 | const trace = new Error().stack?.split(/\n/g)[6] || ""; 152 | 153 | const start = performance.now(); 154 | depth += 1; 155 | 156 | const action: Action = { 157 | id, 158 | name, 159 | instance: storeId, 160 | payload: payload.map(exomeToJsonDepth), 161 | now: start, 162 | depth, 163 | trace, 164 | 165 | before, 166 | }; 167 | 168 | addAction(action); 169 | connection.send({ 170 | event: "send", 171 | type: "action", 172 | payload: action, 173 | }); 174 | 175 | return (error, response) => { 176 | if (error !== undefined) { 177 | action.error = String(error); 178 | } 179 | if (response !== undefined) { 180 | action.response = exomeToJsonDepth(response); 181 | } 182 | action.time = performance.now() - start; 183 | action.after = exomeToJson(instance); 184 | 185 | depth -= 1; 186 | 187 | connection.send({ 188 | event: "update", 189 | type: "action", 190 | payload: action, 191 | }); 192 | }; 193 | }; 194 | 195 | function addAction(action: Action) { 196 | fullActions.push(action); 197 | 198 | if (fullActions.length > maxAge) { 199 | fullActions.splice(0, maxAge); 200 | } 201 | } 202 | }; 203 | 204 | function exomeToJson(instance: Exome): Record { 205 | const proto = Object.getPrototypeOf(instance); 206 | const methodNames = Object.getOwnPropertyNames(proto); 207 | const propertyNames = Object.getOwnPropertyNames(instance).filter( 208 | (key) => methodNames.indexOf(key) === -1, 209 | ); 210 | 211 | const data: Record = {}; 212 | 213 | for (const methodName of methodNames) { 214 | if (methodName === "constructor") { 215 | continue; 216 | } 217 | 218 | const isGetter = typeof descriptor(proto, methodName)?.get === "function"; 219 | 220 | if (isGetter) { 221 | // @TODO lazy request getter value via subscription 222 | data[`$$exome_gt:${methodName}`] = null; 223 | continue; 224 | } 225 | 226 | data[`$$exome_ac:${methodName}`] = String("@TODO"); 227 | } 228 | 229 | for (const propertyName of propertyNames) { 230 | const value = descriptor(instance, propertyName)?.value; 231 | const isMethod = typeof value === "function"; 232 | 233 | if (isMethod) { 234 | data[`$$exome_sl:${propertyName}`] = propertyName; 235 | continue; 236 | } 237 | 238 | data[propertyName] = exomeToJsonDepth(value); 239 | } 240 | 241 | return data; 242 | } 243 | 244 | function exomeToJsonDepth(instance: any) { 245 | if (instance === undefined) { 246 | return instance; 247 | } 248 | 249 | try { 250 | return JSON.parse( 251 | JSON.stringify( 252 | instance, 253 | (key, value) => { 254 | if (value == null || typeof value !== "object") { 255 | return value; 256 | } 257 | 258 | if (value instanceof Exome) { 259 | return { 260 | $$exome_id: getExomeId(value), 261 | }; 262 | } 263 | 264 | if ( 265 | value.constructor.name !== "Array" && 266 | value.constructor.name !== "Object" && 267 | value.constructor.name !== "Date" 268 | ) { 269 | return { 270 | $$exome_class: value.constructor.name, 271 | }; 272 | } 273 | 274 | return value; 275 | }, 276 | 2, 277 | ), 278 | ); 279 | } catch (_e) { 280 | return undefined; 281 | } 282 | } 283 | 284 | interface Action { 285 | id: string; 286 | name: string; 287 | payload: any[]; 288 | instance: string; 289 | depth: number; 290 | now: number; 291 | time?: number; 292 | trace: string; 293 | error?: string; 294 | response?: string; 295 | before: Record; 296 | after?: Record; 297 | } 298 | -------------------------------------------------------------------------------- /src/jest/serializer.test.ts: -------------------------------------------------------------------------------- 1 | import proxyquire from "proxyquire"; 2 | import { test } from "uvu"; 3 | import assert from "uvu/assert"; 4 | 5 | import { Exome } from "../constructor"; 6 | import { GhostExome } from "../ghost"; 7 | 8 | const { print, test: testSerializer } = proxyquire 9 | .noCallThru() 10 | .load("./serializer.ts", { 11 | exome: { 12 | Exome, 13 | }, 14 | "exome/ghost": { 15 | GhostExome, 16 | }, 17 | }); 18 | 19 | function mockPrettyPrint(value: any) { 20 | if (testSerializer(value)) { 21 | return print(value, mockPrettyPrint); 22 | } 23 | 24 | if (Array.isArray(value)) { 25 | return [ 26 | "[", 27 | " " + 28 | value 29 | .reduce((acc, value) => { 30 | return acc.concat(`${mockPrettyPrint(value)},`); 31 | }, [] as string[]) 32 | .join("\n") 33 | .replace(/\n/g, "\n "), 34 | "]", 35 | ] 36 | .join("\n") 37 | .replace("\n \n", ""); 38 | } 39 | 40 | if (value && typeof value === "object") { 41 | return [ 42 | "{", 43 | " " + 44 | Object.entries(value) 45 | .reduce((acc, [key, value]) => { 46 | return acc.concat( 47 | `${JSON.stringify(key)}: ${mockPrettyPrint(value)},`, 48 | ); 49 | }, [] as string[]) 50 | .join("\n") 51 | .replace(/\n/g, "\n "), 52 | "}", 53 | ] 54 | .join("\n") 55 | .replace("\n \n", ""); 56 | } 57 | 58 | return JSON.stringify(value); 59 | } 60 | 61 | test("exports `print`", () => { 62 | assert.ok(print); 63 | }); 64 | 65 | test("that `print` is function", () => { 66 | assert.instance(print, Function); 67 | }); 68 | 69 | test("exports `test`", () => { 70 | assert.ok(testSerializer); 71 | }); 72 | 73 | test("that `test` is function", () => { 74 | assert.instance(testSerializer, Function); 75 | }); 76 | 77 | test("`test` returns `true` when encountering instance of Exome", () => { 78 | const output = testSerializer(new Exome()); 79 | 80 | assert.is(output, true); 81 | }); 82 | 83 | test("`test` returns `true` when encountering instance of GhostExome", () => { 84 | const output = testSerializer(new GhostExome()); 85 | 86 | assert.is(output, true); 87 | }); 88 | 89 | test("`test` returns `true` when encountering instance of extended Exome", () => { 90 | class Extended extends Exome {} 91 | const output = testSerializer(new Extended()); 92 | 93 | assert.is(output, true); 94 | }); 95 | 96 | test("`test` returns `true` when encountering instance of extended GhostExome", () => { 97 | class Extended extends GhostExome {} 98 | const output = testSerializer(new Extended()); 99 | 100 | assert.is(output, true); 101 | }); 102 | 103 | test("`test` returns `false` when encountering `Exome`", () => { 104 | const output = testSerializer(Exome); 105 | 106 | assert.is(output, false); 107 | }); 108 | 109 | test("`test` returns `false` when encountering `GhostExome`", () => { 110 | const output = testSerializer(GhostExome); 111 | 112 | assert.is(output, false); 113 | }); 114 | 115 | test("`test` returns `false` when encountering `undefined`", () => { 116 | const output = testSerializer(undefined); 117 | 118 | assert.is(output, false); 119 | }); 120 | 121 | test("`test` returns `false` when encountering `null`", () => { 122 | const output = testSerializer(null); 123 | 124 | assert.is(output, false); 125 | }); 126 | 127 | test("`test` returns `false` when encountering `0`", () => { 128 | const output = testSerializer(0); 129 | 130 | assert.is(output, false); 131 | }); 132 | 133 | test("`test` returns `false` when encountering `1`", () => { 134 | const output = testSerializer(1); 135 | 136 | assert.is(output, false); 137 | }); 138 | 139 | test('`test` returns `false` when encountering ""', () => { 140 | const output = testSerializer(""); 141 | 142 | assert.is(output, false); 143 | }); 144 | 145 | test('`test` returns `false` when encountering "foo"', () => { 146 | const output = testSerializer("foo"); 147 | 148 | assert.is(output, false); 149 | }); 150 | 151 | test("`test` returns `false` when encountering `{}`", () => { 152 | const output = testSerializer({}); 153 | 154 | assert.is(output, false); 155 | }); 156 | 157 | test("`test` returns `false` when encountering `[]`", () => { 158 | const output = testSerializer([]); 159 | 160 | assert.is(output, false); 161 | }); 162 | 163 | test("`print` outputs empty Exome instance", () => { 164 | const output = print(new Exome(), mockPrettyPrint); 165 | 166 | assert.snapshot(output, "Exome {}"); 167 | }); 168 | 169 | test("`print` outputs empty extended Exome instance", () => { 170 | class Extended extends Exome {} 171 | const output = print(new Extended(), mockPrettyPrint); 172 | 173 | assert.snapshot(output, "Extended {}"); 174 | }); 175 | 176 | test("`print` outputs filled extended Exome instance", () => { 177 | class Extended extends Exome { 178 | public name?: string; 179 | public foo = "bar"; 180 | 181 | public get gotName() { 182 | return "this will not show"; 183 | } 184 | 185 | public methodFoo() {} 186 | private methodBar() {} 187 | } 188 | const output = print(new Extended(), mockPrettyPrint); 189 | 190 | assert.snapshot( 191 | output, 192 | `Extended { 193 | "foo": "bar", 194 | }`, 195 | ); 196 | }); 197 | 198 | test("`print` outputs nested extended Exome instance", () => { 199 | class Dog extends Exome { 200 | constructor(public name: string) { 201 | super(); 202 | } 203 | } 204 | class Owner extends Exome { 205 | public dogs = [new Dog("Andy")]; 206 | } 207 | const output = print(new Owner(), mockPrettyPrint); 208 | 209 | assert.snapshot( 210 | output, 211 | `Owner { 212 | "dogs": [ 213 | Dog { 214 | "name": "Andy", 215 | }, 216 | ], 217 | }`, 218 | ); 219 | }); 220 | 221 | test("`print` outputs empty GhostExome instance", () => { 222 | const output = print(new GhostExome(), mockPrettyPrint); 223 | 224 | assert.snapshot(output, "GhostExome {}"); 225 | }); 226 | 227 | test("`print` outputs empty extended GhostExome instance", () => { 228 | class Extended extends GhostExome {} 229 | const output = print(new Extended(), mockPrettyPrint); 230 | 231 | assert.snapshot(output, "Extended {}"); 232 | }); 233 | 234 | test("`print` outputs filled extended GhostExome instance", () => { 235 | class Extended extends GhostExome { 236 | public name?: string; 237 | public foo = "bar"; 238 | 239 | public get gotName() { 240 | return "this will not show"; 241 | } 242 | 243 | public methodFoo() {} 244 | private methodBar() {} 245 | } 246 | const output = print(new Extended(), mockPrettyPrint); 247 | 248 | assert.snapshot( 249 | output, 250 | `Extended { 251 | "foo": "bar", 252 | }`, 253 | ); 254 | }); 255 | 256 | test("`print` outputs nested extended GhostExome instance", () => { 257 | class Dog extends GhostExome { 258 | constructor(public name: string) { 259 | super(); 260 | } 261 | } 262 | class Owner extends GhostExome { 263 | public dogs = [new Dog("Andy")]; 264 | } 265 | const output = print(new Owner(), mockPrettyPrint); 266 | 267 | assert.snapshot( 268 | output, 269 | `Owner { 270 | "dogs": [ 271 | Dog { 272 | "name": "Andy", 273 | }, 274 | ], 275 | }`, 276 | ); 277 | }); 278 | 279 | test("`print` outputs untitled class", () => { 280 | const output = print(new (class {})(), mockPrettyPrint); 281 | 282 | assert.snapshot(output, "Exome {}"); 283 | }); 284 | 285 | test("`print` handles circular reference", () => { 286 | class Data extends Exome { 287 | constructor(public extra: Data[] = []) { 288 | super(); 289 | } 290 | } 291 | 292 | const circular = new Data(); 293 | circular.extra.push(circular); 294 | 295 | const input = new Data([circular, new Data([circular]), circular]); 296 | const output = print(input, mockPrettyPrint); 297 | 298 | assert.snapshot( 299 | output, 300 | `Data { 301 | "extra": [ 302 | Data { 303 | "extra": [ 304 | Data [circular], 305 | ], 306 | }, 307 | Data { 308 | "extra": [ 309 | Data { 310 | "extra": [ 311 | Data [circular], 312 | ], 313 | }, 314 | ], 315 | }, 316 | Data { 317 | "extra": [ 318 | Data [circular], 319 | ], 320 | }, 321 | ], 322 | }`, 323 | ); 324 | }); 325 | 326 | test.run(); 327 | -------------------------------------------------------------------------------- /src/on-action.test.ts: -------------------------------------------------------------------------------- 1 | import { fake } from "sinon"; 2 | import { test } from "uvu"; 3 | import assert from "uvu/assert"; 4 | 5 | import { Exome } from "./constructor"; 6 | import { middleware, runMiddleware } from "./middleware"; 7 | 8 | import { onAction } from "./on-action"; 9 | 10 | test.before.each(() => { 11 | middleware.splice(0, 100); 12 | }); 13 | 14 | test("exports `onAction`", () => { 15 | assert.ok(onAction); 16 | }); 17 | 18 | test("that `onAction` is function", () => { 19 | assert.instance(onAction, Function); 20 | }); 21 | 22 | test("adds before middleware without errors", () => { 23 | class Person extends Exome { 24 | constructor(public name?: string) { 25 | super(); 26 | } 27 | 28 | public rename(name: string) { 29 | this.name = name; 30 | } 31 | } 32 | 33 | const person = new Person("John"); 34 | const handler = fake(); 35 | 36 | onAction(Person, "rename", handler, "before"); 37 | 38 | const after = runMiddleware(person, "rename", [1]); 39 | 40 | assert.equal(handler.callCount, 1); 41 | assert.equal(handler.args[0], [person, "rename", [1]]); 42 | 43 | after(); 44 | 45 | assert.equal(handler.callCount, 1); 46 | }); 47 | 48 | test("adds after middleware without errors", () => { 49 | class Person extends Exome { 50 | constructor(public name?: string) { 51 | super(); 52 | } 53 | 54 | public rename(name: string) { 55 | this.name = name; 56 | } 57 | } 58 | 59 | const person = new Person("John"); 60 | const handler = fake(); 61 | 62 | onAction(Person, "rename", handler, "after"); 63 | 64 | const after = runMiddleware(person, "rename", [1]); 65 | 66 | assert.equal(handler.callCount, 0); 67 | 68 | after(); 69 | 70 | assert.equal(handler.callCount, 1); 71 | assert.equal(handler.args[0], [person, "rename", [1], undefined, undefined]); 72 | }); 73 | 74 | test("adds after middleware with error", () => { 75 | class Person extends Exome { 76 | constructor(public name?: string) { 77 | super(); 78 | } 79 | 80 | public rename(name: string) { 81 | this.name = name; 82 | } 83 | } 84 | 85 | const person = new Person("John"); 86 | const handler = fake(); 87 | 88 | onAction(Person, "rename", handler, "after"); 89 | 90 | const after = runMiddleware(person, "rename", [1]); 91 | 92 | assert.equal(handler.callCount, 0); 93 | 94 | after(new Error("test error")); 95 | 96 | assert.equal(handler.callCount, 1); 97 | assert.equal(handler.args[0], [ 98 | person, 99 | "rename", 100 | [1], 101 | new Error("test error"), 102 | undefined, 103 | ]); 104 | }); 105 | 106 | test("adds after middleware with response", () => { 107 | class Person extends Exome { 108 | constructor(public name?: string) { 109 | super(); 110 | } 111 | 112 | public rename(name: string) { 113 | this.name = name; 114 | } 115 | } 116 | 117 | const person = new Person("John"); 118 | const handler = fake(); 119 | 120 | onAction(Person, "rename", handler, "after"); 121 | 122 | const after = runMiddleware(person, "rename", [1]); 123 | 124 | assert.equal(handler.callCount, 0); 125 | 126 | after(undefined, "test response"); 127 | 128 | assert.equal(handler.callCount, 1); 129 | assert.equal(handler.args[0], [ 130 | person, 131 | "rename", 132 | [1], 133 | undefined, 134 | "test response", 135 | ]); 136 | }); 137 | 138 | test("calls NEW action correctly", async () => { 139 | class Person extends Exome { 140 | constructor(public name?: string) { 141 | super(); 142 | } 143 | 144 | public rename(name: string) { 145 | this.name = name; 146 | } 147 | } 148 | 149 | const handler = fake(); 150 | onAction(Person, "NEW", handler); 151 | 152 | assert.equal(handler.callCount, 0); 153 | 154 | new Person("John"); 155 | 156 | await new Promise((resolve) => setTimeout(resolve, 0)); 157 | 158 | assert.equal(handler.callCount, 1); 159 | assert.instance(handler.args[0][0], Person); 160 | assert.equal(handler.args[0][1], "NEW"); 161 | assert.equal(handler.args[0][2].length, 0); 162 | }); 163 | 164 | test("calls LOAD_STATE action correctly before", async () => { 165 | class Person extends Exome { 166 | constructor(public name?: string) { 167 | super(); 168 | } 169 | 170 | public rename(name: string) { 171 | this.name = name; 172 | } 173 | } 174 | 175 | const handler = fake(); 176 | onAction(Person, "LOAD_STATE", handler, "before"); 177 | 178 | assert.equal(handler.callCount, 0); 179 | 180 | const instance = new Person("John"); 181 | 182 | runMiddleware(instance, "LOAD_STATE", []); 183 | 184 | assert.equal(handler.callCount, 1); 185 | assert.instance(handler.args[0][0], Person); 186 | assert.equal(handler.args[0][1], "LOAD_STATE"); 187 | assert.equal(handler.args[0][2].length, 0); 188 | }); 189 | 190 | test("calls LOAD_STATE action correctly after", async () => { 191 | class Person extends Exome { 192 | constructor(public name?: string) { 193 | super(); 194 | } 195 | 196 | public rename(name: string) { 197 | this.name = name; 198 | } 199 | } 200 | 201 | const handler = fake(); 202 | onAction(Person, "LOAD_STATE", handler, "after"); 203 | 204 | assert.equal(handler.callCount, 0); 205 | 206 | const instance = new Person("John"); 207 | 208 | await new Promise((resolve) => setTimeout(resolve, 0)); 209 | 210 | const after = runMiddleware(instance, "LOAD_STATE", []); 211 | after(); 212 | 213 | assert.equal(handler.callCount, 1); 214 | assert.instance(handler.args[0][0], Person); 215 | assert.equal(handler.args[0][1], "LOAD_STATE"); 216 | assert.equal(handler.args[0][2].length, 0); 217 | }); 218 | 219 | test("calls any action correctly", async () => { 220 | class Person extends Exome { 221 | constructor(public name?: string) { 222 | super(); 223 | } 224 | 225 | public rename(name: string) { 226 | this.name = name; 227 | } 228 | } 229 | 230 | const handler = fake(); 231 | onAction(Person, null, handler); 232 | 233 | assert.equal(handler.callCount, 0); 234 | 235 | const person = new Person("John"); 236 | 237 | await new Promise((resolve) => setTimeout(resolve, 0)); 238 | 239 | assert.equal(handler.callCount, 1); 240 | assert.instance(handler.args[0][0], Person); 241 | assert.equal(handler.args[0][1], "NEW"); 242 | assert.equal(handler.args[0][2].length, 0); 243 | 244 | person.rename("Jane"); 245 | 246 | assert.equal(handler.callCount, 2); 247 | assert.equal(handler.args[1], [ 248 | person, 249 | "rename", 250 | ["Jane"], 251 | undefined, 252 | undefined, 253 | ]); 254 | }); 255 | 256 | test("calls custom action correctly", () => { 257 | class Person extends Exome { 258 | constructor(public name?: string) { 259 | super(); 260 | } 261 | 262 | public rename(name: string) { 263 | this.name = name; 264 | } 265 | } 266 | 267 | const handler = fake(); 268 | onAction(Person, "rename", handler); 269 | 270 | assert.equal(handler.callCount, 0); 271 | 272 | const person = new Person("John"); 273 | 274 | assert.equal(handler.callCount, 0); 275 | 276 | person.rename("Jane"); 277 | 278 | assert.equal(handler.callCount, 1); 279 | assert.equal(handler.args[0], [ 280 | person, 281 | "rename", 282 | ["Jane"], 283 | undefined, 284 | undefined, 285 | ]); 286 | }); 287 | 288 | test("calls custom action correctly with error", () => { 289 | class Person extends Exome { 290 | constructor(public name?: string) { 291 | super(); 292 | } 293 | 294 | public rename(name: string) { 295 | throw new Error("Test error in action"); 296 | } 297 | } 298 | 299 | const handler = fake(); 300 | onAction(Person, "rename", handler); 301 | 302 | assert.equal(handler.callCount, 0); 303 | 304 | const person = new Person("John"); 305 | 306 | assert.equal(handler.callCount, 0); 307 | 308 | assert.throws(() => person.rename("Jane"), "Test error in action"); 309 | 310 | assert.equal(handler.callCount, 1); 311 | assert.equal(handler.args[0], [ 312 | person, 313 | "rename", 314 | ["Jane"], 315 | new Error("Test error in action"), 316 | undefined, 317 | ]); 318 | }); 319 | 320 | test("calls custom action correctly with response", () => { 321 | class Person extends Exome { 322 | constructor(public name?: string) { 323 | super(); 324 | } 325 | 326 | public rename(name: string) { 327 | return "Test response in action"; 328 | } 329 | } 330 | 331 | const handler = fake(); 332 | onAction(Person, "rename", handler); 333 | 334 | assert.equal(handler.callCount, 0); 335 | 336 | const person = new Person("John"); 337 | 338 | assert.equal(handler.callCount, 0); 339 | 340 | person.rename("Jane"); 341 | 342 | assert.equal(handler.callCount, 1); 343 | assert.equal(handler.args[0], [ 344 | person, 345 | "rename", 346 | ["Jane"], 347 | undefined, 348 | "Test response in action", 349 | ]); 350 | }); 351 | 352 | test("calls custom async action correctly with error", async () => { 353 | class Person extends Exome { 354 | constructor(public name?: string) { 355 | super(); 356 | } 357 | 358 | public async rename(name: string) { 359 | throw new Error("Test error in action"); 360 | } 361 | } 362 | 363 | const handler = fake(); 364 | onAction(Person, "rename", handler); 365 | 366 | assert.equal(handler.callCount, 0); 367 | 368 | const person = new Person("John"); 369 | 370 | assert.equal(handler.callCount, 0); 371 | 372 | try { 373 | await person.rename("Jane"); 374 | assert.unreachable("should have thrown"); 375 | } catch (err) { 376 | assert.instance(err, Error); 377 | assert.match(err.message, "Test error in action"); 378 | } 379 | 380 | assert.equal(handler.callCount, 1); 381 | assert.equal(handler.args[0], [ 382 | person, 383 | "rename", 384 | ["Jane"], 385 | new Error("Test error in action"), 386 | undefined, 387 | ]); 388 | }); 389 | 390 | test("calls custom async action correctly with response", async () => { 391 | class Person extends Exome { 392 | constructor(public name?: string) { 393 | super(); 394 | } 395 | 396 | public async rename(name: string) { 397 | return "Test response in action"; 398 | } 399 | } 400 | 401 | const handler = fake(); 402 | onAction(Person, "rename", handler); 403 | 404 | assert.equal(handler.callCount, 0); 405 | 406 | const person = new Person("John"); 407 | 408 | assert.equal(handler.callCount, 0); 409 | 410 | await person.rename("Jane"); 411 | 412 | assert.equal(handler.callCount, 1); 413 | assert.equal(handler.args[0], [ 414 | person, 415 | "rename", 416 | ["Jane"], 417 | undefined, 418 | "Test response in action", 419 | ]); 420 | }); 421 | 422 | test("unsubscribes correctly", () => { 423 | class Person extends Exome { 424 | constructor(public name?: string) { 425 | super(); 426 | } 427 | 428 | public rename(name: string) { 429 | this.name = name; 430 | } 431 | } 432 | 433 | const handler = fake(); 434 | const unsubscribe = onAction(Person, "rename", handler); 435 | 436 | assert.equal(handler.callCount, 0); 437 | 438 | const person = new Person("John"); 439 | 440 | assert.equal(handler.callCount, 0); 441 | 442 | person.rename("Jane"); 443 | 444 | assert.equal(handler.callCount, 1); 445 | 446 | person.rename("Janine"); 447 | 448 | assert.equal(handler.callCount, 2); 449 | 450 | unsubscribe(); 451 | 452 | assert.equal(handler.callCount, 2); 453 | }); 454 | 455 | test.run(); 456 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | CI 5 | 6 | 7 | npm 8 | 9 | 10 | jsr 11 | 12 | 13 | package size 14 | 15 | 16 | State manager for deeply nested states. Includes integration for [React](#react), [Preact](#preact), [Vue](#vue), [Svelte](#svelte), [Solid](#solid), [Lit](#lit), [Rxjs](#rxjs), [Angular](#angular) & [No framework](#no-framework). Can be easily used in microfrontends architecture. 17 | 18 | # Features 19 | 20 | - 📦 **Small**: Just **1 KB** minizipped 21 | - 🚀 **Fast**: Uses **no diffing** of state changes see [**benchmarks**](https://marcisbee.com/js-store-benchmark?focus=exome) 22 | - 😍 **Simple**: Uses classes as state, methods as actions 23 | - 🛡 **Typed**: Written in strict TypeScript 24 | - 🔭 **Devtools**: Redux devtools integration 25 | - 💨 **Zero dependencies** 26 | 27 | ```ts 28 | // store/counter.ts 29 | import { Exome } from "exome" 30 | 31 | export class Counter extends Exome { 32 | public count = 0 33 | 34 | public increment() { 35 | this.count += 1 36 | } 37 | } 38 | 39 | export const counter = new Counter() 40 | ``` 41 | 42 | ```tsx 43 | // components/counter.tsx 44 | import { useStore } from "exome/react" 45 | import { counter } from "../stores/counter.ts" 46 | 47 | export default function App() { 48 | const { count, increment } = useStore(counter) 49 | 50 | return ( 51 |

    {count}

    52 | ) 53 | } 54 | ``` 55 | 56 | [__Simple Demo__](https://dune.land/dune/468e79c1-e31b-4035-bc19-b03dfa363060) 57 | 58 | # Table of contents 59 | 60 | - [Core concepts](#core-concepts) 61 | - [Usage](#usage) 62 | - Integration 63 | - [React](#react) 64 | - [Preact](#preact) 65 | - [Vue](#vue) 66 | - [Svelte](#svelte) 67 | - [Solid](#solid) 68 | - [Lit](#lit) 69 | - [Rxjs](#rxjs) 70 | - [Angular](#angular) 71 | - [No framework](#no-framework) 72 | - [Redux devtools](#redux-devtools) 73 | - [API](#api) 74 | - [FAQ](#faq) 75 | - [**Benchmarks**](https://marcisbee.com/js-store-benchmark?focus=exome) 76 | - [Motivation](#motivation) 77 | 78 | # Installation 79 | To install the stable version: 80 | ```bash 81 | npm install --save exome 82 | ``` 83 | This assumes you are using [npm](https://www.npmjs.com/package/exome) as your package manager. 84 | 85 | # Core concepts 86 | Any piece of state you have, must use a class that extends `Exome`. 87 | 88 | `Stores` 89 | 90 | Store can be a single class or multiple ones. I'd suggest keeping stores small, in terms of property sizes. 91 | 92 | `State values` 93 | 94 | Remember that this is quite a regular class (with some behind the scenes logic). So you can write you data inside properties however you'd like. Properties can be public, private, object, arrays, getters, setters, static etc. 95 | 96 | `Actions` 97 | 98 | Every method in class is considered as an action. They are only for changing state. Whenever any method is called in Exome it triggers update to middleware and updates view components. Actions can be regular methods or even async ones. 99 | 100 | If you want to get something from state via method, use getters. 101 | 102 | # Usage 103 | Library can be used without typescript, but I mostly recommend using it with typescript as it will guide you through what can and cannot be done as there are no checks without it and can lead to quite nasty bugs. 104 | 105 | To create a typed store just create new class with a name of your choosing by extending `Exome` class exported from `exome` library. 106 | 107 | ```ts 108 | import { Exome } from "exome" 109 | 110 | // We'll have a store called "CounterStore" 111 | class CounterStore extends Exome { 112 | // Lets set up one property "count" with default value "0" 113 | public count = 0 114 | 115 | // Now lets create action that will update "count" value 116 | public increment() { 117 | this.count += 1 118 | } 119 | } 120 | ``` 121 | [__Open in dune.land__](https://dune.land/dune/468e79c1-e31b-4035-bc19-b03dfa363060) 122 | 123 | That is the basic structure of simple store. It can have as many properties as you'd like. There are no restrictions. 124 | 125 | Now we should create an instance of `CounterStore` to use it. 126 | 127 | ```ts 128 | const counter = new CounterStore() 129 | ``` 130 | 131 | Nice! Now we can start using `counter` state. 132 | 133 | # Integration 134 | ## React 135 | Use `useStore()` from `exome/react` to get store value and re-render component on store change. 136 | 137 | ```tsx 138 | import { useStore } from "exome/react" 139 | import { counter } from "../stores/counter.ts" 140 | 141 | export function Example() { 142 | const { count, increment } = useStore(counter) 143 | return 144 | } 145 | ``` 146 | 147 | ## Preact 148 | Use `useStore()` from `exome/preact` to get store value and re-render component on store change. 149 | 150 | ```tsx 151 | import { useStore } from "exome/preact" 152 | import { counter } from "../stores/counter.ts" 153 | 154 | export function Example() { 155 | const { count, increment } = useStore(counter) 156 | return 157 | } 158 | ``` 159 | 160 | ## Vue 161 | Use `useStore()` from `exome/vue` to get store value and re-render component on store change. 162 | 163 | ```html 164 | 170 | 171 | 174 | ``` 175 | 176 | ## Svelte 177 | Use `useStore()` from `exome/svelte` to get store value and re-render component on store change. 178 | 179 | ```html 180 | 187 | 188 |
    189 | 190 |
    191 | ``` 192 | 193 | ## Solid 194 | Use `useStore()` from `exome/solid` to get store value and update signal selector on store change. 195 | 196 | ```tsx 197 | import { useStore } from "exome/solid" 198 | import { counter } from "../stores/counter.ts" 199 | 200 | export function Example() { 201 | const count = useStore(counter, s => s.count) 202 | return 203 | } 204 | ``` 205 | 206 | ## Lit 207 | Use `StoreController` from `exome/lit` to get store value and re-render component on store change. 208 | 209 | ```ts 210 | import { StoreController } from "exome/lit" 211 | import { counter } from "./store/counter.js" 212 | 213 | @customElement("counter") 214 | class extends LitElement { 215 | private counter = new StoreController(this, counter); 216 | 217 | render() { 218 | const { count, increment } = this.counter.store; 219 | 220 | return html` 221 |

    ${count}

    222 | `; 223 | } 224 | } 225 | ``` 226 | 227 | ## Rxjs 228 | Use `observableFromExome` from `exome/rxjs` to get store value as Observable and trigger it when it changes. 229 | 230 | ```ts 231 | import { observableFromExome } from "exome/rxjs" 232 | import { counter } from "./store/counter.js" 233 | 234 | observableFromExome(countStore) 235 | .pipe( 236 | map(({ count }) => count), 237 | distinctUntilChanged() 238 | ) 239 | .subscribe((value) => { 240 | console.log("Count changed to", value); 241 | }); 242 | 243 | setInterval(counter.increment, 1000); 244 | ``` 245 | 246 | ## Angular 247 | ### signals (>=16) 248 | Use `useStore` from `exome/angular` to get store value and update signal selector on store change. 249 | 250 | ```ts 251 | import { useStore } from "exome/angular" 252 | import { counter } from "./store/counter.ts" 253 | 254 | @Component({ 255 | selector: 'my-app', 256 | template: ` 257 |

    258 | {{count}} 259 |

    260 | `, 261 | }) 262 | export class App { 263 | public count = useStore(counter, (s) => s.count); 264 | public increment() { 265 | counter.increment(); 266 | } 267 | } 268 | ``` 269 | 270 | ### observables (<=15) 271 | Angular support is handled via rxjs async pipes! 272 | 273 | Use `observableFromExome` from `exome/rxjs` to get store value as Observable and trigger it when it changes. 274 | 275 | ```ts 276 | import { observableFromExome } from "exome/rxjs" 277 | import { counter } from "./store/counter.ts" 278 | 279 | @Component({ 280 | selector: 'my-app', 281 | template: ` 282 |

    283 | {{counter.count}} 284 |

    285 | `, 286 | }) 287 | export class App { 288 | public counter$ = observableFromExome(counter) 289 | } 290 | ``` 291 | 292 | ## No framework 293 | Use `subscribe` from `exome` to get store value in subscription callback event when it changes. 294 | 295 | ```ts 296 | import { subscribe } from "exome" 297 | import { counter } from "./store/counter.js" 298 | 299 | const unsubscribe = subscribe(counter, ({ count }) => { 300 | console.log("Count changed to", count) 301 | }) 302 | 303 | setInterval(counter.increment, 1000) 304 | setTimeout(unsubscribe, 5000) 305 | ``` 306 | 307 | # Redux devtools 308 | 309 | You can use redux devtools extension to explore Exome store chunk by chunk. 310 | 311 | Just add `exomeReduxDevtools` middleware via `addMiddleware` function exported by library before you start defining store. 312 | 313 | ```ts 314 | import { addMiddleware } from 'exome' 315 | import { exomeReduxDevtools } from 'exome/devtools' 316 | 317 | addMiddleware( 318 | exomeReduxDevtools({ 319 | name: 'Exome Playground' 320 | }) 321 | ) 322 | ``` 323 | 324 | It all will look something like this: 325 | 326 | ![Exome using Redux Devtools](https://user-images.githubusercontent.com/16621507/115083737-871c3d00-9f10-11eb-94e7-21353d093a7e.png) 327 | 328 | # API 329 | ### `Exome` 330 | A class with underlying logic that handles state changes. Every store must be extended from this class. 331 | 332 | ```ts 333 | abstract class Exome {} 334 | ``` 335 | 336 | ### `useStore` 337 | Is function exported from "exome/react". 338 | 339 | ```ts 340 | function useStore(store: T): Readonly 341 | ``` 342 | 343 | __Arguments__ 344 | 1. `store` _([Exome](#exome))_: State to watch changes from. Without Exome being passed in this function, react component will not be updated when particular Exome updates. 345 | 346 | __Returns__ 347 | 348 | - [_Exome_](#exome): Same store is returned. 349 | 350 | __Example__ 351 | 352 | ```tsx 353 | import { useStore } from "exome/react" 354 | 355 | const counter = new Counter() 356 | 357 | function App() { 358 | const { count, increment } = useStore(counter) 359 | 360 | return 361 | } 362 | ``` 363 | [__Open in dune.land__](https://dune.land/dune/468e79c1-e31b-4035-bc19-b03dfa363060) 364 | 365 | ### `onAction` 366 | Function that calls callback whenever specific action on Exome is called. 367 | 368 | ```ts 369 | function onAction(store: typeof Exome): Unsubscribe 370 | ``` 371 | 372 | __Arguments__ 373 | 1. `store` _([Exome](#exome) constructor)_: Store that has desired action to listen to. 374 | 2. `action` _(string)_: method (action) name on store instance. 375 | 3. `callback` _(Function)_: Callback that will be triggered before or after action.
    376 | __Arguments__ 377 | - `instance` _([Exome](#exome))_: Instance where action is taking place. 378 | - `action` _(String)_: Action name. 379 | - `payload` _(any[])_: Array of arguments passed in action.
    380 | 4. `type` _("before" | "after")_: when to run callback - before or after action, default is `"after"`. 381 | 382 | __Returns__ 383 | 384 | - _Function_: Unsubscribes this action listener 385 | 386 | __Example__ 387 | 388 | ```tsx 389 | import { onAction } from "exome" 390 | 391 | const unsubscribe = onAction( 392 | Person, 393 | 'rename', 394 | (instance, action, payload) => { 395 | console.log(`Person ${instance} was renamed to ${payload[0]}`); 396 | 397 | // Unsubscribe is no longer needed 398 | unsubscribe(); 399 | }, 400 | 'before' 401 | ) 402 | ``` 403 | 404 | ### `saveState` 405 | Function that saves snapshot of current state for any Exome and returns string. 406 | 407 | ```ts 408 | function saveState(store: Exome): string 409 | ``` 410 | 411 | __Arguments__ 412 | 1. `store` _([Exome](#exome))_: State to save state from (will save full state tree with nested Exomes). 413 | 414 | __Returns__ 415 | 416 | - _String_: Stringified Exome instance 417 | 418 | __Example__ 419 | 420 | ```tsx 421 | import { saveState } from "exome/state" 422 | 423 | const saved = saveState(counter) 424 | ``` 425 | 426 | ### `loadState` 427 | Function that loads saved state in any Exome instance. 428 | 429 | ```ts 430 | function loadState( 431 | store: Exome, 432 | state: string 433 | ): Record 434 | ``` 435 | 436 | __Arguments__ 437 | 1. `store` _([Exome](#exome))_: Store to load saved state to. 438 | 2. `state` _(String)_: Saved state string from `saveState` output. 439 | 440 | __Returns__ 441 | 442 | - _Object_: Data that is loaded into state, but without Exome instance (if for any reason you have to have this data). 443 | 444 | __Example__ 445 | 446 | ```ts 447 | import { loadState, registerLoadable } from "exome/state" 448 | 449 | registerLoadable({ 450 | Counter 451 | }) 452 | 453 | const newCounter = new Counter() 454 | 455 | const loaded = loadState(newCounter, saved) 456 | loaded.count // e.g. = 15 457 | loaded.increment // undefined 458 | 459 | newCounter.count // new counter instance has all of the state applied so also = 15 460 | newCounter.increment // [Function] 461 | ``` 462 | 463 | ### `registerLoadable` 464 | Function that registers Exomes that can be loaded from saved state via [`loadState`](#loadState). 465 | 466 | ```ts 467 | function registerLoadable( 468 | config: Record, 469 | ): void 470 | ``` 471 | 472 | __Arguments__ 473 | 1. `config` _(Object)_: Saved state string from `saveState` output. 474 | - key _(String)_: Name of the Exome state class (e.g. `"Counter"`). 475 | - value _([Exome](#exome) constructor)_: Class of named Exome (e.g. `Counter`). 476 | 477 | __Returns__ 478 | 479 | - _void_ 480 | 481 | __Example__ 482 | 483 | ```ts 484 | import { loadState, registerLoadable } from "exome/state" 485 | 486 | registerLoadable({ 487 | Counter, 488 | SampleStore 489 | }) 490 | ``` 491 | 492 | ### `addMiddleware` 493 | Function that adds middleware to Exome. It takes in callback that will be called every time before an action is called. 494 | 495 | React hook integration is actually a middleware. 496 | 497 | ```ts 498 | type Middleware = (instance: Exome, action: string, payload: any[]) => (void | Function) 499 | 500 | function addMiddleware(fn: Middleware): void 501 | ``` 502 | 503 | __Arguments__ 504 | 1. `callback` _(Function)_: Callback that will be triggered `BEFORE` action is started.
    505 | __Arguments__ 506 | - `instance` _([Exome](#exome))_: Instance where action is taking place. 507 | - `action` _(String)_: Action name. 508 | - `payload` _(any[])_: Array of arguments passed in action.
    509 | 510 | __Returns__ 511 | - _(void | Function)_: Callback can return function that will be called `AFTER` action is completed. 512 | 513 | __Returns__ 514 | 515 | - _void_: Nothingness... 516 | 517 | __Example__ 518 | 519 | ```ts 520 | import { Exome, addMiddleware } from "exome" 521 | 522 | addMiddleware((instance, name, payload) => { 523 | if (!(instance instanceof Timer)) { 524 | return; 525 | } 526 | 527 | console.log(`before action "${name}"`, instance.time); 528 | 529 | return () => { 530 | console.log(`after action "${name}"`, instance.time); 531 | }; 532 | }); 533 | 534 | class Timer extends Exome { 535 | public time = 0; 536 | 537 | public increment() { 538 | this.time += 1; 539 | } 540 | } 541 | 542 | const timer = new Timer() 543 | 544 | setInterval(timer.increment, 1000) 545 | 546 | // > before action "increment", 0 547 | // > after action "increment", 1 548 | // ... after 1s 549 | // > before action "increment", 1 550 | // > after action "increment", 2 551 | // ... 552 | ``` 553 | [__Open in Codesandbox__](https://codesandbox.io/s/exome-middleware-ro6of?file=/src/App.tsx) 554 | 555 | # FAQ 556 | ### Q: Can I use Exome inside Exome? 557 | YES! It was designed for that exact purpose. 558 | Exome can have deeply nested Exomes inside itself. And whenever new Exome is used in child component, it has to be wrapped in `useStore` hook and that's the only rule. 559 | 560 | For example: 561 | ```tsx 562 | class Todo extends Exome { 563 | constructor(public message: string, public completed = false) { 564 | super(); 565 | } 566 | 567 | public toggle() { 568 | this.completed = !this.completed; 569 | } 570 | } 571 | 572 | class Store extends Exome { 573 | constructor(public list: Todo[]) { 574 | super(); 575 | } 576 | } 577 | 578 | const store = new Store([ 579 | new Todo("Code a new state library", true), 580 | new Todo("Write documentation") 581 | ]); 582 | 583 | function TodoView({ todo }: { todo: Todo }) { 584 | const { message, completed, toggle } = useStore(todo); 585 | 586 | return ( 587 |
  • 588 | 593 | {message} 594 | 595 |   596 | 597 |
  • 598 | ); 599 | } 600 | 601 | function App() { 602 | const { list } = useStore(store); 603 | 604 | return ( 605 |
      606 | {list.map((todo) => ( 607 | 608 | ))} 609 |
    610 | ); 611 | } 612 | ``` 613 | [__Open in dune.land__](https://dune.land/dune/e557f934-e0b6-4ef7-96c0-cd9ff12c7c0b) 614 | 615 | ### Q: Can deep state structure be saved to string and then loaded back as an instance? 616 | YES! This was also one of key requirements for this. We can save full state from any Exome with [`saveState`](#saveState), save it to file or database and the load that string up onto Exome instance with [`loadState`](#loadState). 617 | 618 | For example: 619 | ```tsx 620 | const savedState = saveState(store) 621 | 622 | const newStore = new Store() 623 | 624 | loadState(newStore, savedState) 625 | ``` 626 | 627 | ### Q: Can I update state outside of React component? 628 | Absolutely. You can even share store across multiple React instances (or if we're looking into future - across multiple frameworks). 629 | 630 | For example: 631 | ```tsx 632 | class Timer extends Exome { 633 | public time = 0 634 | 635 | public increment() { 636 | this.time += 1 637 | } 638 | } 639 | 640 | const timer = new Timer() 641 | 642 | setInterval(timer.increment, 1000) 643 | 644 | function App() { 645 | const { time } = useStore(timer) 646 | 647 | return

    {time}

    648 | } 649 | ``` 650 | [__Open in Codesandbox__](https://codesandbox.io/s/exome-middleware-ro6of?file=/src/App.tsx) 651 | 652 | # IE support 653 | To run Exome on IE, you must have `Symbol` and `Promise` polyfills and down-transpile to ES5 as usual. And that's it! 654 | 655 | # Motivation 656 | I stumbled upon a need to store deeply nested store and manage chunks of them individually and regular flux selector/action architecture just didn't make much sense anymore. So I started to prototype what would ideal deeply nested store interaction look like and I saw that we could simply use classes for this. 657 | 658 | **Goals I set for this project:** 659 | 660 | - [x] Easy usage with deeply nested state chunks (array in array) 661 | - [x] Type safe with TypeScript 662 | - [x] To have actions be only way of editing state 663 | - [x] To have effects trigger extra actions 664 | - [x] Redux devtool support 665 | 666 | # License 667 | [MIT](LICENCE) © [Marcis Bergmanis](https://twitter.com/marcisbee) 668 | --------------------------------------------------------------------------------