├── .gitattributes ├── .prettierignore ├── .gitignore ├── benchmarks ├── gc.ts ├── js-reactivity-benchmarks │ ├── kairo │ │ ├── mux.bench.ts │ │ ├── deep.bench.ts │ │ ├── repeated.bench.ts │ │ ├── diamond.bench.ts │ │ ├── unstable.bench.ts │ │ ├── broad.bench.ts │ │ ├── avoidable.bench.ts │ │ └── triangle.bench.ts │ ├── molBench.bench.ts │ ├── cellxBench.bench.ts │ ├── sBench.bench.ts │ └── dynamic.bench.ts ├── jsonArrayReporter.ts └── basic.bench.ts ├── .prettierrc ├── tsconfig.d.json ├── tsconfig.json ├── DEVELOPER.md ├── typedoc.json ├── test.ts ├── src ├── internal │ ├── equal.ts │ ├── unsubscribe.ts │ ├── storeWithOnUse.ts │ ├── untrack.ts │ ├── storeConst.ts │ ├── storeSubscribable.ts │ ├── store.ts │ ├── exposeRawStores.ts │ ├── subscribeConsumer.ts │ ├── batch.ts │ ├── storeComputedOrDerived.ts │ ├── storeTrackingUsage.ts │ ├── storeComputed.ts │ ├── storeDerived.ts │ └── storeWritable.ts ├── types.ts └── index.ts ├── vitest.config.ts ├── .github └── workflows │ ├── update.yml │ ├── ci.yml │ └── release.yml ├── LICENSE ├── eslint.config.mjs ├── rollup.config.mjs ├── package.json └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | temp 4 | *.md 5 | coverage 6 | .angular 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | temp 4 | coverage 5 | .angular 6 | benchmarks.json 7 | -------------------------------------------------------------------------------- /benchmarks/gc.ts: -------------------------------------------------------------------------------- 1 | const gc = globalThis.gc; 2 | export const setup = gc 3 | ? () => { 4 | gc(); 5 | } 6 | : () => {}; 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "printWidth": 100, 5 | "semi": true, 6 | "singleQuote": true 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.d.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist/package", 5 | "declaration": true, 6 | "emitDeclarationOnly": true 7 | }, 8 | "files": ["src/index.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "experimentalDecorators": true, 4 | "module": "ES2015", 5 | "target": "ES2022", 6 | "moduleResolution": "Node", 7 | "strict": true, 8 | "noImplicitOverride": true, 9 | "useDefineForClassFields": false 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /DEVELOPER.md: -------------------------------------------------------------------------------- 1 | Run: 2 | 3 | - `npm test` to execute unit tests. 4 | - `npm run tdd` or `npm run tdd:ui` to execute unit tests in the TDD mode (tests are re-run on each change). 5 | - `npm run build` to build the lib 6 | - `npm run format:check` to check that files are correctly formatted 7 | - `npm run format:fix` to reformat files 8 | - `npm run docs` to generate the documentation 9 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "entryPoints": ["src/index.ts"], 3 | "excludeInternal": true, 4 | "excludePrivate": true, 5 | "kindSortOrder": ["Function", "Class"], 6 | "out": "./dist/docs", 7 | "treatValidationWarningsAsErrors": true, 8 | "intentionallyNotExported": ["__global.SymbolConstructor.observable"], 9 | "tsconfig": "./tsconfig.d.json", 10 | "visibilityFilters": {} 11 | } 12 | -------------------------------------------------------------------------------- /test.ts: -------------------------------------------------------------------------------- 1 | // This file is used by vitest 2 | 3 | import 'zone.js'; 4 | import 'zone.js/testing'; 5 | import { getTestBed } from '@angular/core/testing'; 6 | import { 7 | BrowserDynamicTestingModule, 8 | platformBrowserDynamicTesting, 9 | } from '@angular/platform-browser-dynamic/testing'; 10 | 11 | // First, initialize the Angular testing environment. 12 | getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting()); 13 | -------------------------------------------------------------------------------- /src/internal/equal.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Default implementation of the equal function used by tansu when a store 3 | * changes, to know if listeners need to be notified. 4 | * Returns false if `a` is a function or an object, or if `a` and `b` 5 | * are different according to `Object.is`. Otherwise, returns true. 6 | * 7 | * @param a - First value to compare. 8 | * @param b - Second value to compare. 9 | * @returns true if a and b are considered equal. 10 | */ 11 | export const equal = (a: T, b: T): boolean => 12 | Object.is(a, b) && (!a || typeof a !== 'object') && typeof a !== 'function'; 13 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | import JsonArrayReporter from './benchmarks/jsonArrayReporter'; 3 | 4 | process.env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --expose-gc`; 5 | 6 | export default defineConfig({ 7 | test: { 8 | setupFiles: ['test.ts'], 9 | include: ['src/**/*.spec.ts'], 10 | environment: 'happy-dom', 11 | coverage: { 12 | provider: 'v8', 13 | reporter: ['lcov'], 14 | include: ['src/**'], 15 | }, 16 | benchmark: { 17 | include: ['benchmarks/**/*.bench.ts'], 18 | reporters: [new JsonArrayReporter(), 'default'], 19 | }, 20 | }, 21 | }); 22 | -------------------------------------------------------------------------------- /src/internal/unsubscribe.ts: -------------------------------------------------------------------------------- 1 | import type { UnsubscribeFunction, UnsubscribeObject, Unsubscriber } from '../types'; 2 | 3 | export const noopUnsubscribe = (): void => {}; 4 | noopUnsubscribe.unsubscribe = noopUnsubscribe; 5 | 6 | export const normalizeUnsubscribe = ( 7 | unsubscribe: Unsubscriber | void | null | undefined 8 | ): UnsubscribeFunction & UnsubscribeObject => { 9 | if (!unsubscribe) { 10 | return noopUnsubscribe; 11 | } 12 | if ((unsubscribe as any).unsubscribe === unsubscribe) { 13 | return unsubscribe as any; 14 | } 15 | const res: any = 16 | typeof unsubscribe === 'function' ? () => unsubscribe() : () => unsubscribe.unsubscribe(); 17 | res.unsubscribe = res; 18 | return res; 19 | }; 20 | -------------------------------------------------------------------------------- /.github/workflows/update.yml: -------------------------------------------------------------------------------- 1 | name: update 2 | 3 | on: 4 | schedule: 5 | - cron: '0 3 2 * *' 6 | workflow_dispatch: 7 | 8 | jobs: 9 | update: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Set Node.js version 14 | uses: actions/setup-node@v4 15 | with: 16 | node-version: '20.x' 17 | cache: npm 18 | - run: npm ci 19 | - run: npx npm-check-updates -u --peer 20 | - run: npm install --ignore-scripts 21 | - run: npm update --ignore-scripts 22 | - name: Create pull request 23 | uses: peter-evans/create-pull-request@8867c4aba1b742c39f8d0ba35429c2dfa4b6cb20 # v7.0.1 24 | with: 25 | commit-message: 'chore: Update all dependencies' 26 | branch: dependencies 27 | title: Update all dependencies 28 | body: Update all dependencies 29 | -------------------------------------------------------------------------------- /src/internal/storeWithOnUse.ts: -------------------------------------------------------------------------------- 1 | import type { UnsubscribeFunction, Unsubscriber } from '../types'; 2 | import { RawStoreFlags } from './store'; 3 | import { RawStoreTrackingUsage } from './storeTrackingUsage'; 4 | import { normalizeUnsubscribe } from './unsubscribe'; 5 | 6 | export class RawStoreWithOnUse extends RawStoreTrackingUsage { 7 | private cleanUpFn: UnsubscribeFunction | null = null; 8 | override flags = RawStoreFlags.HAS_VISIBLE_ONUSE; 9 | 10 | constructor( 11 | value: T, 12 | private readonly onUseFn: () => Unsubscriber | void 13 | ) { 14 | super(value); 15 | } 16 | 17 | override startUse(): void { 18 | this.cleanUpFn = normalizeUnsubscribe(this.onUseFn()); 19 | } 20 | 21 | override endUse(): void { 22 | const cleanUpFn = this.cleanUpFn; 23 | if (cleanUpFn) { 24 | this.cleanUpFn = null; 25 | cleanUpFn(); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /benchmarks/js-reactivity-benchmarks/kairo/mux.bench.ts: -------------------------------------------------------------------------------- 1 | // adapted from https://github.com/milomg/js-reactivity-benchmark/blob/main/src/kairo/mux.ts 2 | 3 | import { bench, expect } from 'vitest'; 4 | import { computed, writable } from '../../../src'; 5 | import { setup } from '../../gc'; 6 | 7 | const heads = new Array(100).fill(null).map(() => writable(0)); 8 | const mux = computed(() => { 9 | return Object.fromEntries(heads.map((h) => h()).entries()); 10 | }); 11 | const splited = heads 12 | .map((_, index) => computed(() => mux()[index])) 13 | .map((x) => computed(() => x() + 1)); 14 | 15 | splited.forEach((x) => { 16 | computed(() => x()).subscribe(() => {}); 17 | }); 18 | 19 | bench( 20 | 'mux', 21 | () => { 22 | for (let i = 0; i < 10; i++) { 23 | heads[i].set(i); 24 | expect(splited[i]()).toBe(i + 1); 25 | } 26 | for (let i = 0; i < 10; i++) { 27 | heads[i].set(i * 2); 28 | expect(splited[i]()).toBe(i * 2 + 1); 29 | } 30 | }, 31 | { throws: true, setup } 32 | ); 33 | -------------------------------------------------------------------------------- /src/internal/untrack.ts: -------------------------------------------------------------------------------- 1 | import type { BaseLink, RawStore } from './store'; 2 | 3 | export interface ActiveConsumer { 4 | addProducer: >(store: RawStore) => T; 5 | } 6 | 7 | export let activeConsumer: ActiveConsumer | null = null; 8 | 9 | export const setActiveConsumer = (consumer: ActiveConsumer | null): ActiveConsumer | null => { 10 | const prevConsumer = activeConsumer; 11 | activeConsumer = consumer; 12 | return prevConsumer; 13 | }; 14 | 15 | /** 16 | * Stops the tracking of dependencies made by {@link computed} and calls the provided function. 17 | * After the function returns, the tracking of dependencies continues as before. 18 | * 19 | * @param fn - function to be called 20 | * @returns the value returned by the given function 21 | */ 22 | export const untrack = (fn: () => T): T => { 23 | let output: T; 24 | const prevActiveConsumer = setActiveConsumer(null); 25 | try { 26 | output = fn(); 27 | } finally { 28 | setActiveConsumer(prevActiveConsumer); 29 | } 30 | return output; 31 | }; 32 | -------------------------------------------------------------------------------- /benchmarks/js-reactivity-benchmarks/kairo/deep.bench.ts: -------------------------------------------------------------------------------- 1 | // adapted from https://github.com/milomg/js-reactivity-benchmark/blob/main/src/kairo/deep.ts 2 | 3 | import { bench, expect } from 'vitest'; 4 | import type { ReadableSignal } from '../../../src'; 5 | import { computed, writable } from '../../../src'; 6 | import { setup } from '../../gc'; 7 | 8 | const len = 50; 9 | 10 | const head = writable(0); 11 | let current = head as ReadableSignal; 12 | for (let i = 0; i < len; i++) { 13 | const c = current; 14 | current = computed(() => { 15 | return c() + 1; 16 | }); 17 | } 18 | let callCounter = 0; 19 | 20 | computed(() => { 21 | current(); 22 | callCounter++; 23 | }).subscribe(() => {}); 24 | 25 | const iter = 50; 26 | 27 | bench( 28 | 'deep', 29 | () => { 30 | head.set(1); 31 | const atleast = iter; 32 | callCounter = 0; 33 | for (let i = 0; i < iter; i++) { 34 | head.set(i); 35 | expect(current()).toBe(len + i); 36 | } 37 | expect(callCounter).toBe(atleast); 38 | }, 39 | { throws: true, setup } 40 | ); 41 | -------------------------------------------------------------------------------- /benchmarks/js-reactivity-benchmarks/kairo/repeated.bench.ts: -------------------------------------------------------------------------------- 1 | // adapted from https://github.com/milomg/js-reactivity-benchmark/blob/main/src/kairo/repeated.ts 2 | 3 | import { bench, expect } from 'vitest'; 4 | import { computed, writable } from '../../../src'; 5 | import { setup } from '../../gc'; 6 | 7 | const size = 30; 8 | 9 | const head = writable(0); 10 | const current = computed(() => { 11 | let result = 0; 12 | for (let i = 0; i < size; i++) { 13 | // tbh I think it's meanigless to be this big... 14 | result += head(); 15 | } 16 | return result; 17 | }); 18 | 19 | let callCounter = 0; 20 | computed(() => { 21 | current(); 22 | callCounter++; 23 | }).subscribe(() => {}); 24 | 25 | bench( 26 | 'repeated', 27 | () => { 28 | head.set(1); 29 | expect(current()).toBe(size); 30 | const atleast = 100; 31 | callCounter = 0; 32 | for (let i = 0; i < 100; i++) { 33 | head.set(i); 34 | expect(current()).toBe(i * size); 35 | } 36 | expect(callCounter).toBe(atleast); 37 | }, 38 | { throws: true, setup } 39 | ); 40 | -------------------------------------------------------------------------------- /benchmarks/js-reactivity-benchmarks/kairo/diamond.bench.ts: -------------------------------------------------------------------------------- 1 | // adapted from https://github.com/milomg/js-reactivity-benchmark/blob/main/src/kairo/diamond.ts 2 | 3 | import { bench, expect } from 'vitest'; 4 | import type { ReadableSignal } from '../../../src'; 5 | import { computed, writable } from '../../../src'; 6 | import { setup } from '../../gc'; 7 | 8 | const width = 5; 9 | 10 | const head = writable(0); 11 | const current: ReadableSignal[] = []; 12 | for (let i = 0; i < width; i++) { 13 | current.push(computed(() => head() + 1)); 14 | } 15 | const sum = computed(() => current.map((x) => x()).reduce((a, b) => a + b, 0)); 16 | let callCounter = 0; 17 | computed(() => { 18 | sum(); 19 | callCounter++; 20 | }).subscribe(() => {}); 21 | 22 | bench( 23 | 'diamond', 24 | () => { 25 | head.set(1); 26 | expect(sum()).toBe(2 * width); 27 | const atleast = 500; 28 | callCounter = 0; 29 | for (let i = 0; i < 500; i++) { 30 | head.set(i); 31 | expect(sum()).toBe((i + 1) * width); 32 | } 33 | expect(callCounter).toBe(atleast); 34 | }, 35 | { throws: true, setup } 36 | ); 37 | -------------------------------------------------------------------------------- /benchmarks/js-reactivity-benchmarks/kairo/unstable.bench.ts: -------------------------------------------------------------------------------- 1 | // adapted from https://github.com/milomg/js-reactivity-benchmark/blob/main/src/kairo/unstable.ts 2 | 3 | import { bench, expect } from 'vitest'; 4 | import { computed, writable } from '../../../src'; 5 | import { setup } from '../../gc'; 6 | 7 | const head = writable(0); 8 | const double = computed(() => head() * 2); 9 | const inverse = computed(() => -head()); 10 | const current = computed(() => { 11 | let result = 0; 12 | for (let i = 0; i < 20; i++) { 13 | result += head() % 2 ? double() : inverse(); 14 | } 15 | return result; 16 | }); 17 | 18 | let callCounter = 0; 19 | computed(() => { 20 | current(); 21 | callCounter++; 22 | }).subscribe(() => {}); 23 | 24 | bench( 25 | 'unstable', 26 | () => { 27 | head.set(1); 28 | expect(current()).toBe(40); 29 | const atleast = 100; 30 | callCounter = 0; 31 | for (let i = 0; i < 100; i++) { 32 | head.set(i); 33 | // expect(current()).toBe(i % 2 ? i * 2 * 10 : i * -10); 34 | } 35 | expect(callCounter).toBe(atleast); 36 | }, 37 | { throws: true, setup } 38 | ); 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright 2021 Amadeus s.a.s. 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 | -------------------------------------------------------------------------------- /benchmarks/js-reactivity-benchmarks/kairo/broad.bench.ts: -------------------------------------------------------------------------------- 1 | // adapted from https://github.com/milomg/js-reactivity-benchmark/blob/main/src/kairo/broad.ts 2 | 3 | import { bench, expect } from 'vitest'; 4 | import type { ReadableSignal } from '../../../src'; 5 | import { computed, writable } from '../../../src'; 6 | import { setup } from '../../gc'; 7 | 8 | const loopCount = 50; 9 | 10 | const head = writable(0); 11 | let last: ReadableSignal = head; 12 | let callCounter = 0; 13 | for (let i = 0; i < loopCount; i++) { 14 | const current = computed(() => { 15 | return head() + i; 16 | }); 17 | const current2 = computed(() => { 18 | return current() + 1; 19 | }); 20 | computed(() => { 21 | current2(); 22 | callCounter++; 23 | }).subscribe(() => {}); 24 | last = current2; 25 | } 26 | 27 | bench( 28 | 'broad', 29 | () => { 30 | head.set(1); 31 | const atleast = loopCount * loopCount; 32 | callCounter = 0; 33 | for (let i = 0; i < loopCount; i++) { 34 | head.set(i); 35 | expect(last()).toBe(i + loopCount); 36 | } 37 | expect(callCounter).toBe(atleast); 38 | }, 39 | { throws: true, setup } 40 | ); 41 | -------------------------------------------------------------------------------- /benchmarks/js-reactivity-benchmarks/kairo/avoidable.bench.ts: -------------------------------------------------------------------------------- 1 | // adapted from https://github.com/milomg/js-reactivity-benchmark/blob/main/src/kairo/avoidable.ts 2 | 3 | import { bench, expect } from 'vitest'; 4 | import { computed, writable } from '../../../src'; 5 | import { setup } from '../../gc'; 6 | 7 | function busy() { 8 | let a = 0; 9 | for (let i = 0; i < 1_00; i++) { 10 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 11 | a++; 12 | } 13 | } 14 | 15 | const head = writable(0); 16 | const computed1 = computed(() => head()); 17 | const computed2 = computed(() => (computed1(), 0)); 18 | const computed3 = computed(() => (busy(), computed2() + 1)); // heavy computation 19 | const computed4 = computed(() => computed3() + 2); 20 | const computed5 = computed(() => computed4() + 3); 21 | computed(() => { 22 | computed5(); 23 | busy(); // heavy side effect 24 | }).subscribe(() => {}); 25 | 26 | bench( 27 | 'avoidablePropagation', 28 | () => { 29 | head.set(1); 30 | expect(computed5()).toBe(6); 31 | for (let i = 0; i < 1000; i++) { 32 | head.set(i); 33 | expect(computed5()).toBe(6); 34 | } 35 | }, 36 | { throws: true, setup } 37 | ); 38 | -------------------------------------------------------------------------------- /benchmarks/jsonArrayReporter.ts: -------------------------------------------------------------------------------- 1 | import type { RunnerTestFile } from 'vitest'; 2 | import type { Reporter } from 'vitest/reporters'; 3 | import { writeFile } from 'fs/promises'; 4 | 5 | class JsonArrayReporter implements Reporter { 6 | async onFinished(files: RunnerTestFile[]): Promise { 7 | const results: { 8 | name: string; 9 | unit: string; 10 | value: number; 11 | }[] = []; 12 | 13 | function processTasks(tasks: RunnerTestFile['tasks'], name: string) { 14 | for (const task of tasks) { 15 | if (task.type === 'suite') { 16 | processTasks(task.tasks, `${name} > ${task.name}`); 17 | } else { 18 | const value = task.result?.benchmark?.hz; 19 | if (value) { 20 | results.push({ 21 | name: `${name} > ${task.name}`, 22 | unit: 'Hz', 23 | value, 24 | }); 25 | } 26 | } 27 | } 28 | } 29 | 30 | for (const file of files) { 31 | processTasks(file.tasks, file.name); 32 | } 33 | 34 | await writeFile('benchmarks.json', JSON.stringify(results, null, ' ')); 35 | } 36 | } 37 | 38 | export default JsonArrayReporter; 39 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import typescriptEslint from '@typescript-eslint/eslint-plugin'; 2 | import tsParser from '@typescript-eslint/parser'; 3 | import path from 'node:path'; 4 | import { fileURLToPath } from 'node:url'; 5 | import js from '@eslint/js'; 6 | import { FlatCompat } from '@eslint/eslintrc'; 7 | 8 | const __filename = fileURLToPath(import.meta.url); 9 | const __dirname = path.dirname(__filename); 10 | const compat = new FlatCompat({ 11 | baseDirectory: __dirname, 12 | recommendedConfig: js.configs.recommended, 13 | allConfig: js.configs.all, 14 | }); 15 | 16 | export default [ 17 | ...compat.extends('eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'), 18 | { 19 | plugins: { 20 | '@typescript-eslint': typescriptEslint, 21 | }, 22 | 23 | languageOptions: { 24 | parser: tsParser, 25 | }, 26 | 27 | rules: { 28 | '@typescript-eslint/no-explicit-any': 0, 29 | '@typescript-eslint/no-empty-function': 0, 30 | '@typescript-eslint/no-non-null-assertion': 0, 31 | '@typescript-eslint/explicit-module-boundary-types': 2, 32 | '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 33 | '@typescript-eslint/consistent-type-imports': 2, 34 | }, 35 | }, 36 | ]; 37 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | branches: [master] 5 | pull_request: 6 | branches: [master] 7 | jobs: 8 | build: 9 | permissions: 10 | contents: write 11 | id-token: write 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-node@v4 16 | with: 17 | cache: npm 18 | node-version: '20.x' 19 | - run: npm ci 20 | - run: npm run lint 21 | - run: npm run format:check 22 | - run: npm test -- --coverage 23 | - uses: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 #v4.5.0 24 | with: 25 | file: coverage/lcov.info 26 | disable_search: true 27 | use_oidc: true 28 | - run: npm run build 29 | - run: npm run docs 30 | - run: npm run benchmark 31 | - uses: benchmark-action/github-action-benchmark@d48d326b4ca9ba73ca0cd0d59f108f9e02a381c7 #v1.20.4 32 | with: 33 | name: Tansu benchmarks 34 | tool: 'customBiggerIsBetter' 35 | output-file-path: benchmarks.json 36 | auto-push: ${{ github.event_name == 'push' }} 37 | github-token: ${{ secrets.GITHUB_TOKEN }} 38 | comment-on-alert: true 39 | summary-always: true 40 | -------------------------------------------------------------------------------- /src/internal/storeConst.ts: -------------------------------------------------------------------------------- 1 | import type { Subscriber, Unsubscriber } from '../types'; 2 | import type { BaseLink, Consumer, RawStore } from './store'; 3 | import { RawStoreFlags } from './store'; 4 | import { checkNotInNotificationPhase } from './storeWritable'; 5 | import { noopUnsubscribe } from './unsubscribe'; 6 | 7 | export class RawStoreConst implements RawStore> { 8 | readonly flags = RawStoreFlags.NONE; 9 | constructor(private readonly value: T) {} 10 | 11 | newLink(_consumer: Consumer): BaseLink { 12 | return { 13 | producer: this, 14 | }; 15 | } 16 | registerConsumer(link: BaseLink): BaseLink { 17 | return link; 18 | } 19 | unregisterConsumer(_link: BaseLink): void {} 20 | updateValue(): void {} 21 | isLinkUpToDate(_link: BaseLink): boolean { 22 | return true; 23 | } 24 | updateLink(_link: BaseLink): T { 25 | return this.value; 26 | } 27 | get(): T { 28 | checkNotInNotificationPhase(); 29 | return this.value; 30 | } 31 | subscribe(subscriber: Subscriber): Unsubscriber { 32 | checkNotInNotificationPhase(); 33 | if (typeof subscriber === 'function') { 34 | subscriber(this.value); 35 | } else { 36 | subscriber?.next?.(this.value); 37 | } 38 | return noopUnsubscribe; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/internal/storeSubscribable.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | SubscribableStore, 3 | SubscriberFunction, 4 | SubscriberObject, 5 | UnsubscribeFunction, 6 | } from '../types'; 7 | import { RawStoreFlags } from './store'; 8 | import { RawStoreTrackingUsage } from './storeTrackingUsage'; 9 | import { normalizeUnsubscribe } from './unsubscribe'; 10 | 11 | export class RawSubscribableWrapper extends RawStoreTrackingUsage { 12 | private readonly subscriber: Pick, 'next'> & SubscriberFunction = 13 | this.createSubscriber(); 14 | private unsubscribe: UnsubscribeFunction | null = null; 15 | override flags = RawStoreFlags.HAS_VISIBLE_ONUSE; 16 | 17 | constructor(private readonly subscribable: SubscribableStore) { 18 | super(undefined as any); 19 | } 20 | 21 | private createSubscriber() { 22 | const subscriber = (value: T) => this.set(value); 23 | subscriber.next = subscriber; 24 | subscriber.pause = () => { 25 | this.markConsumersDirty(); 26 | }; 27 | return subscriber; 28 | } 29 | 30 | override startUse(): void { 31 | this.unsubscribe = normalizeUnsubscribe(this.subscribable.subscribe(this.subscriber)); 32 | } 33 | 34 | override endUse(): void { 35 | const unsubscribe = this.unsubscribe; 36 | if (unsubscribe) { 37 | this.unsubscribe = null; 38 | unsubscribe(); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /benchmarks/js-reactivity-benchmarks/kairo/triangle.bench.ts: -------------------------------------------------------------------------------- 1 | // adapted from https://github.com/milomg/js-reactivity-benchmark/blob/main/src/kairo/triangle.ts 2 | 3 | import { bench, expect } from 'vitest'; 4 | import type { ReadableSignal } from '../../../src'; 5 | import { computed, writable } from '../../../src'; 6 | import { setup } from '../../gc'; 7 | 8 | const width = 10; 9 | 10 | const head = writable(0); 11 | let current = head as ReadableSignal; 12 | const list: ReadableSignal[] = []; 13 | for (let i = 0; i < width; i++) { 14 | const c = current; 15 | list.push(current); 16 | current = computed(() => { 17 | return c() + 1; 18 | }); 19 | } 20 | const sum = computed(() => { 21 | return list.map((x) => x()).reduce((a, b) => a + b, 0); 22 | }); 23 | 24 | let callCounter = 0; 25 | 26 | computed(() => { 27 | sum(); 28 | callCounter++; 29 | }).subscribe(() => {}); 30 | 31 | bench( 32 | 'triangle', 33 | () => { 34 | const constant = count(width); 35 | head.set(1); 36 | expect(sum()).toBe(constant); 37 | const atleast = 100; 38 | callCounter = 0; 39 | for (let i = 0; i < 100; i++) { 40 | head.set(i); 41 | expect(sum()).toBe(constant - width + i * width); 42 | } 43 | expect(callCounter).toBe(atleast); 44 | }, 45 | { throws: true, setup } 46 | ); 47 | 48 | function count(number: number) { 49 | return new Array(number) 50 | .fill(0) 51 | .map((_, i) => i + 1) 52 | .reduce((x, y) => x + y, 0); 53 | } 54 | -------------------------------------------------------------------------------- /benchmarks/js-reactivity-benchmarks/molBench.bench.ts: -------------------------------------------------------------------------------- 1 | // adapted from https://github.com/milomg/js-reactivity-benchmark/blob/main/src/molBench.ts 2 | 3 | import { bench } from 'vitest'; 4 | import { batch, computed, writable } from '../../src'; 5 | import { setup } from '../gc'; 6 | 7 | function fib(n: number): number { 8 | if (n < 2) return 1; 9 | return fib(n - 1) + fib(n - 2); 10 | } 11 | 12 | function hard(n: number) { 13 | return n + fib(16); 14 | } 15 | 16 | const numbers = Array.from({ length: 5 }, (_, i) => i); 17 | 18 | const res = []; 19 | 20 | const A = writable(0); 21 | const B = writable(0); 22 | const C = computed(() => (A() % 2) + (B() % 2)); 23 | const D = computed(() => numbers.map((i) => ({ x: i + (A() % 2) - (B() % 2) }))); 24 | const E = computed(() => hard(C() + A() + D()[0].x /*, 'E'*/)); 25 | const F = computed(() => hard(D()[2].x || B() /*, 'F'*/)); 26 | const G = computed(() => C() + (C() || E() % 2) + D()[4].x + F()); 27 | computed(() => res.push(hard(G() /*, 'H'*/))).subscribe(() => {}); 28 | computed(() => res.push(G())).subscribe(() => {}); 29 | computed(() => res.push(hard(F() /*, 'J'*/))).subscribe(() => {}); 30 | 31 | bench( 32 | 'molBench', 33 | () => { 34 | for (let i = 0; i < 1e4; i++) { 35 | res.length = 0; 36 | batch(() => { 37 | B.set(1); 38 | A.set(1 + i * 2); 39 | }); 40 | batch(() => { 41 | A.set(2 + i * 2); 42 | B.set(2); 43 | }); 44 | } 45 | }, 46 | { throws: true, setup } 47 | ); 48 | -------------------------------------------------------------------------------- /src/internal/store.ts: -------------------------------------------------------------------------------- 1 | import type { SignalStore, SubscribableStore } from '../types'; 2 | 3 | export interface Consumer { 4 | markDirty(): void; 5 | } 6 | 7 | export const enum RawStoreFlags { 8 | NONE = 0, 9 | // the following flags are used in RawStoreTrackingUsage and derived classes 10 | HAS_VISIBLE_ONUSE = 1, 11 | START_USE_CALLED = 1 << 1, 12 | FLUSH_PLANNED = 1 << 2, 13 | // the following flags are used in RawStoreComputedOrDerived and derived classes 14 | COMPUTING = 1 << 3, 15 | DIRTY = 1 << 4, 16 | } 17 | 18 | export interface BaseLink { 19 | producer: RawStore>; 20 | skipMarkDirty?: boolean; 21 | } 22 | 23 | export interface RawStore = BaseLink> 24 | extends SignalStore, 25 | SubscribableStore { 26 | readonly flags: RawStoreFlags; 27 | newLink(consumer: Consumer): Link; 28 | registerConsumer(link: Link): Link; 29 | unregisterConsumer(link: Link): void; 30 | updateValue(): void; 31 | isLinkUpToDate(link: Link): boolean; 32 | updateLink(link: Link): T; 33 | } 34 | 35 | export const updateLinkProducerValue = (link: BaseLink): void => { 36 | try { 37 | link.skipMarkDirty = true; 38 | link.producer.updateValue(); 39 | // Ignoring coverage for the following lines because, unless there is a bug in tansu (which would have to be fixed!) 40 | // there should be no way to trigger this error. 41 | /* v8 ignore next 3 */ 42 | if (link.producer.flags & RawStoreFlags.DIRTY) { 43 | throw new Error('assert failed: store still dirty after updating it'); 44 | } 45 | } finally { 46 | link.skipMarkDirty = false; 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /src/internal/exposeRawStores.ts: -------------------------------------------------------------------------------- 1 | import type { Readable, ReadableSignal, StoreInput } from '../types'; 2 | import type { RawStore } from './store'; 3 | import { RawSubscribableWrapper } from './storeSubscribable'; 4 | 5 | /** 6 | * Symbol used in {@link InteropObservable} allowing any object to expose an observable. 7 | */ 8 | export const symbolObservable: typeof Symbol.observable = 9 | (typeof Symbol === 'function' && Symbol.observable) || ('@@observable' as any); 10 | 11 | const returnThis = function (this: T): T { 12 | return this; 13 | }; 14 | 15 | export const rawStoreSymbol = Symbol(); 16 | const rawStoreMap = new WeakMap, RawStore>(); 17 | 18 | export const getRawStore = (storeInput: StoreInput): RawStore => { 19 | const rawStore = (storeInput as any)[rawStoreSymbol]; 20 | if (rawStore) { 21 | return rawStore; 22 | } 23 | let res = rawStoreMap.get(storeInput); 24 | if (!res) { 25 | let subscribable = storeInput; 26 | if (!('subscribe' in subscribable)) { 27 | subscribable = subscribable[symbolObservable](); 28 | } 29 | res = new RawSubscribableWrapper(subscribable); 30 | rawStoreMap.set(storeInput, res); 31 | } 32 | return res; 33 | }; 34 | 35 | export const exposeRawStore = ( 36 | rawStore: RawStore, 37 | extraProp?: U 38 | ): ReadableSignal & Omit> => { 39 | const get = rawStore.get.bind(rawStore) as any; 40 | if (extraProp) { 41 | Object.assign(get, extraProp); 42 | } 43 | get.get = get; 44 | get.subscribe = rawStore.subscribe.bind(rawStore); 45 | get[symbolObservable] = returnThis; 46 | get[rawStoreSymbol] = rawStore; 47 | return get; 48 | }; 49 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | version: 6 | type: string 7 | required: true 8 | description: Version number (x.y.z) 9 | 10 | jobs: 11 | release: 12 | runs-on: ubuntu-latest 13 | permissions: 14 | id-token: write 15 | contents: write 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: '20.x' 21 | cache: npm 22 | registry-url: 'https://registry.npmjs.org' 23 | - run: npm ci 24 | - run: | 25 | git config --global user.name github-actions 26 | git config --global user.email github-actions@github.com 27 | - if: inputs.version != 'doconly' 28 | run: npm version ${{ inputs.version }} 29 | - run: git show HEAD 30 | - run: npm run build 31 | - run: npm run docs 32 | - run: | 33 | npm whoami 34 | npm publish --access=public --provenance 35 | working-directory: dist/package 36 | if: inputs.version != 'doconly' 37 | env: 38 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 39 | - run: git push origin master v${{ inputs.version }} 40 | if: inputs.version != 'doconly' 41 | - uses: actions/checkout@v4 42 | with: 43 | ref: gh-pages 44 | path: gh-pages 45 | - name: Publish documentation 46 | working-directory: gh-pages 47 | run: | 48 | mv dev .. 49 | rm -rf * 50 | cp -a ../dist/docs/* . 51 | mv ../dev . 52 | git add . 53 | git commit --allow-empty -a -m "Updating from ${{ github.sha }}" 54 | git push origin gh-pages 55 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import typescript from '@rollup/plugin-typescript'; 2 | import assert from 'assert'; 3 | import { readFile } from 'fs/promises'; 4 | import { defineConfig } from 'rollup'; 5 | 6 | const distPackagePrefix = './dist/package/'; 7 | const removeDistPackage = (withDistPackage) => { 8 | assert(withDistPackage.startsWith(distPackagePrefix)); 9 | return `./${withDistPackage.substring(distPackagePrefix.length)}`; 10 | }; 11 | 12 | export default defineConfig({ 13 | output: [ 14 | { 15 | format: 'cjs', 16 | file: './dist/package/index.cjs', 17 | }, 18 | { 19 | format: 'es', 20 | file: './dist/package/index.js', 21 | }, 22 | ], 23 | input: './src/index.ts', 24 | plugins: [ 25 | typescript(), 26 | { 27 | name: 'package', 28 | async buildStart() { 29 | const pkg = JSON.parse(await readFile('./package.json', 'utf8')); 30 | delete pkg.private; 31 | delete pkg.devDependencies; 32 | delete pkg.scripts; 33 | delete pkg.husky; 34 | delete pkg.commitlint; 35 | delete pkg.files; 36 | pkg.typings = removeDistPackage(pkg.typings); 37 | pkg.main = removeDistPackage(pkg.main); 38 | pkg.module = removeDistPackage(pkg.module); 39 | pkg.exports.types = removeDistPackage(pkg.exports.types); 40 | pkg.exports.require = removeDistPackage(pkg.exports.require); 41 | pkg.exports.default = removeDistPackage(pkg.exports.default); 42 | this.emitFile({ type: 'asset', fileName: 'package.json', source: JSON.stringify(pkg) }); 43 | this.emitFile({ 44 | type: 'asset', 45 | fileName: 'README.md', 46 | source: await readFile('README.md', 'utf-8'), 47 | }); 48 | this.emitFile({ 49 | type: 'asset', 50 | fileName: 'LICENSE', 51 | source: await readFile('LICENSE', 'utf-8'), 52 | }); 53 | }, 54 | }, 55 | ], 56 | }); 57 | -------------------------------------------------------------------------------- /src/internal/subscribeConsumer.ts: -------------------------------------------------------------------------------- 1 | import type { Subscriber, SubscriberObject } from '../types'; 2 | import { subscribersQueue } from './batch'; 3 | import { updateLinkProducerValue, type BaseLink, type Consumer, type RawStore } from './store'; 4 | 5 | export const noop = (): void => {}; 6 | 7 | const bind = (object: T | null | undefined, fnName: keyof T) => { 8 | const fn = object ? object[fnName] : null; 9 | return typeof fn === 'function' ? fn.bind(object) : noop; 10 | }; 11 | 12 | const noopSubscriber: SubscriberObject = { 13 | next: noop, 14 | pause: noop, 15 | resume: noop, 16 | }; 17 | 18 | const toSubscriberObject = (subscriber: Subscriber): SubscriberObject => ({ 19 | next: typeof subscriber === 'function' ? subscriber.bind(null) : bind(subscriber, 'next'), 20 | pause: bind(subscriber, 'pause'), 21 | resume: bind(subscriber, 'resume'), 22 | }); 23 | 24 | export class SubscribeConsumer> implements Consumer { 25 | private readonly link: Link; 26 | private subscriber: SubscriberObject; 27 | dirtyCount = 1; 28 | constructor(producer: RawStore, subscriber: Subscriber) { 29 | this.subscriber = toSubscriberObject(subscriber); 30 | this.link = producer.registerConsumer(producer.newLink(this)); 31 | this.notify(true); 32 | } 33 | 34 | unsubscribe(): void { 35 | if (this.subscriber !== noopSubscriber) { 36 | this.subscriber = noopSubscriber; 37 | this.link.producer.unregisterConsumer(this.link); 38 | } 39 | } 40 | 41 | markDirty(): void { 42 | this.dirtyCount++; 43 | subscribersQueue.push(this); 44 | if (this.dirtyCount === 1) { 45 | this.subscriber.pause(); 46 | } 47 | } 48 | 49 | notify(first = false): void { 50 | this.dirtyCount--; 51 | if (this.dirtyCount === 0 && this.subscriber !== noopSubscriber) { 52 | const link = this.link; 53 | const producer = link.producer; 54 | updateLinkProducerValue(link); 55 | if (producer.isLinkUpToDate(link) && !first) { 56 | this.subscriber.resume(); 57 | } else { 58 | // note that the following line can throw 59 | const value = producer.updateLink(link); 60 | this.subscriber.next(value); 61 | } 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /benchmarks/js-reactivity-benchmarks/cellxBench.bench.ts: -------------------------------------------------------------------------------- 1 | // adapted from https://github.com/milomg/js-reactivity-benchmark/blob/main/src/cellxBench.ts 2 | 3 | import { bench, expect } from 'vitest'; 4 | import { batch, computed, writable } from '../../src'; 5 | import type { ReadableSignal } from '../../src'; 6 | import { setup } from '../gc'; 7 | 8 | // The following is an implementation of the cellx benchmark https://github.com/Riim/cellx/blob/master/perf/perf.html 9 | 10 | const cellx = ( 11 | layers: number, 12 | expectedBefore: readonly [number, number, number, number], 13 | expectedAfter: readonly [number, number, number, number] 14 | ) => { 15 | const start = { 16 | prop1: writable(1), 17 | prop2: writable(2), 18 | prop3: writable(3), 19 | prop4: writable(4), 20 | }; 21 | 22 | let layer: { 23 | prop1: ReadableSignal; 24 | prop2: ReadableSignal; 25 | prop3: ReadableSignal; 26 | prop4: ReadableSignal; 27 | } = start; 28 | 29 | for (let i = layers; i > 0; i--) { 30 | const m = layer; 31 | const s = { 32 | prop1: computed(() => m.prop2()), 33 | prop2: computed(() => m.prop1() - m.prop3()), 34 | prop3: computed(() => m.prop2() + m.prop4()), 35 | prop4: computed(() => m.prop3()), 36 | }; 37 | 38 | computed(() => s.prop1()).subscribe(() => {}); 39 | computed(() => s.prop2()).subscribe(() => {}); 40 | computed(() => s.prop3()).subscribe(() => {}); 41 | computed(() => s.prop4()).subscribe(() => {}); 42 | 43 | s.prop1(); 44 | s.prop2(); 45 | s.prop3(); 46 | s.prop4(); 47 | 48 | layer = s; 49 | } 50 | 51 | const end = layer; 52 | 53 | expect(end.prop1()).toBe(expectedBefore[0]); 54 | expect(end.prop2()).toBe(expectedBefore[1]); 55 | expect(end.prop3()).toBe(expectedBefore[2]); 56 | expect(end.prop4()).toBe(expectedBefore[3]); 57 | 58 | batch(() => { 59 | start.prop1.set(4); 60 | start.prop2.set(3); 61 | start.prop3.set(2); 62 | start.prop4.set(1); 63 | }); 64 | 65 | expect(end.prop1()).toBe(expectedAfter[0]); 66 | expect(end.prop2()).toBe(expectedAfter[1]); 67 | expect(end.prop3()).toBe(expectedAfter[2]); 68 | expect(end.prop4()).toBe(expectedAfter[3]); 69 | }; 70 | 71 | type BenchmarkResults = [ 72 | readonly [number, number, number, number], 73 | readonly [number, number, number, number], 74 | ]; 75 | 76 | const expected: Record = { 77 | 1000: [ 78 | [-3, -6, -2, 2], 79 | [-2, -4, 2, 3], 80 | ], 81 | 2500: [ 82 | [-3, -6, -2, 2], 83 | [-2, -4, 2, 3], 84 | ], 85 | 5000: [ 86 | [2, 4, -1, -6], 87 | [-2, 1, -4, -4], 88 | ], 89 | }; 90 | 91 | for (const layers in expected) { 92 | const params = expected[layers]; 93 | bench(`cellx${layers}`, () => cellx(+layers, params[0], params[1]), { throws: true, setup }); 94 | } 95 | -------------------------------------------------------------------------------- /src/internal/batch.ts: -------------------------------------------------------------------------------- 1 | import type { SubscribeConsumer } from './subscribeConsumer'; 2 | 3 | export const subscribersQueue: SubscribeConsumer[] = []; 4 | let willProcessQueue = false; 5 | 6 | /** 7 | * Batches multiple changes to stores while calling the provided function, 8 | * preventing derived stores from updating until the function returns, 9 | * to avoid unnecessary recomputations. 10 | * 11 | * @remarks 12 | * 13 | * If a store is updated multiple times in the provided function, existing 14 | * subscribers of that store will only be called once when the provided 15 | * function returns. 16 | * 17 | * Note that even though the computation of derived stores is delayed in most 18 | * cases, some computations of derived stores will still occur inside 19 | * the function provided to batch if a new subscriber is added to a store, because 20 | * calling {@link SubscribableStore.subscribe | subscribe} always triggers a 21 | * synchronous call of the subscriber and because tansu always provides up-to-date 22 | * values when calling subscribers. Especially, calling {@link get} on a store will 23 | * always return the correct up-to-date value and can trigger derived store 24 | * intermediate computations, even inside batch. 25 | * 26 | * It is possible to have nested calls of batch, in which case only the first 27 | * (outer) call has an effect, inner calls only call the provided function. 28 | * 29 | * @param fn - a function that can update stores. Its returned value is 30 | * returned by the batch function. 31 | * 32 | * @example 33 | * Using batch in the following example prevents logging the intermediate "Sherlock Lupin" value. 34 | * 35 | * ```typescript 36 | * const firstName = writable('Arsène'); 37 | * const lastName = writable('Lupin'); 38 | * const fullName = derived([firstName, lastName], ([a, b]) => `${a} ${b}`); 39 | * fullName.subscribe((name) => console.log(name)); // logs any change to fullName 40 | * batch(() => { 41 | * firstName.set('Sherlock'); 42 | * lastName.set('Holmes'); 43 | * }); 44 | * ``` 45 | */ 46 | export const batch = (fn: () => T): T => { 47 | const needsProcessQueue = !willProcessQueue; 48 | willProcessQueue = true; 49 | let success = true; 50 | let res; 51 | let error; 52 | try { 53 | res = fn(); 54 | } finally { 55 | if (needsProcessQueue) { 56 | while (subscribersQueue.length > 0) { 57 | const consumer = subscribersQueue.shift()!; 58 | try { 59 | consumer.notify(); 60 | } catch (e) { 61 | // an error in one consumer should not impact others 62 | if (success) { 63 | // will throw the first error 64 | success = false; 65 | error = e; 66 | } 67 | } 68 | } 69 | willProcessQueue = false; 70 | } 71 | } 72 | if (success) { 73 | return res; 74 | } 75 | throw error; 76 | }; 77 | -------------------------------------------------------------------------------- /src/internal/storeComputedOrDerived.ts: -------------------------------------------------------------------------------- 1 | import type { Consumer } from './store'; 2 | import { RawStoreFlags } from './store'; 3 | import { RawStoreTrackingUsage } from './storeTrackingUsage'; 4 | import { setActiveConsumer } from './untrack'; 5 | 6 | const MAX_CHANGE_RECOMPUTES = 1000; 7 | 8 | export const COMPUTED_UNSET: any = Symbol('UNSET'); 9 | export const COMPUTED_ERRORED: any = Symbol('ERRORED'); 10 | export const isComputedSpecialValue = (value: unknown): boolean => 11 | value === COMPUTED_UNSET || value === COMPUTED_ERRORED; 12 | 13 | export abstract class RawStoreComputedOrDerived 14 | extends RawStoreTrackingUsage 15 | implements Consumer 16 | { 17 | override flags = RawStoreFlags.DIRTY; 18 | error: any; 19 | 20 | override equal(a: T, b: T): boolean { 21 | if (isComputedSpecialValue(a) || isComputedSpecialValue(b)) { 22 | return false; 23 | } 24 | return super.equal(a, b); 25 | } 26 | 27 | markDirty(): void { 28 | if (!(this.flags & RawStoreFlags.DIRTY)) { 29 | this.flags |= RawStoreFlags.DIRTY; 30 | this.markConsumersDirty(); 31 | } 32 | } 33 | 34 | override readValue(): T { 35 | const value = this.value; 36 | if (value === COMPUTED_ERRORED) { 37 | throw this.error; 38 | } 39 | // Ignoring coverage for the following lines because, unless there is a bug in tansu (which would have to be fixed!) 40 | // there should be no way to trigger this error. 41 | /* v8 ignore next 3 */ 42 | if (value === COMPUTED_UNSET) { 43 | throw new Error('assert failed: computed value is not set'); 44 | } 45 | return value; 46 | } 47 | 48 | override updateValue(): void { 49 | if (this.flags & RawStoreFlags.COMPUTING) { 50 | throw new Error('recursive computed'); 51 | } 52 | super.updateValue(); 53 | if (!(this.flags & RawStoreFlags.DIRTY)) { 54 | return; 55 | } 56 | this.flags |= RawStoreFlags.COMPUTING; 57 | const prevActiveConsumer = setActiveConsumer(null); 58 | try { 59 | let iterations = 0; 60 | do { 61 | do { 62 | iterations++; 63 | this.flags &= ~RawStoreFlags.DIRTY; 64 | if (this.areProducersUpToDate()) { 65 | return; 66 | } 67 | } while (this.flags & RawStoreFlags.DIRTY && iterations < MAX_CHANGE_RECOMPUTES); 68 | this.recompute(); 69 | } while (this.flags & RawStoreFlags.DIRTY && iterations < MAX_CHANGE_RECOMPUTES); 70 | if (this.flags & RawStoreFlags.DIRTY) { 71 | this.flags &= ~RawStoreFlags.DIRTY; 72 | this.error = new Error('reached maximum number of store changes in one shot'); 73 | this.set(COMPUTED_ERRORED); 74 | } 75 | } finally { 76 | setActiveConsumer(prevActiveConsumer); 77 | this.flags &= ~RawStoreFlags.COMPUTING; 78 | } 79 | } 80 | 81 | abstract areProducersUpToDate(): boolean; 82 | abstract recompute(): void; 83 | } 84 | -------------------------------------------------------------------------------- /benchmarks/basic.bench.ts: -------------------------------------------------------------------------------- 1 | import { bench, describe } from 'vitest'; 2 | import type { ReadableSignal, Unsubscriber } from '../src'; 3 | import { computed, derived, DerivedStore, readable, Store, writable } from '../src'; 4 | import { setup } from './gc'; 5 | 6 | class StoreClass extends Store {} 7 | class DoubleStoreClass extends DerivedStore> { 8 | protected override derive(value: number): Unsubscriber | void { 9 | this.set(2 * value); 10 | } 11 | } 12 | 13 | describe('creating base stores', () => { 14 | bench( 15 | 'writable', 16 | () => { 17 | writable(0); 18 | }, 19 | { setup } 20 | ); 21 | bench( 22 | 'readable', 23 | () => { 24 | readable(0); 25 | }, 26 | { setup } 27 | ); 28 | 29 | bench( 30 | 'new StoreClass', 31 | () => { 32 | new StoreClass(0); 33 | }, 34 | { setup } 35 | ); 36 | }); 37 | 38 | describe('creating derived stores', () => { 39 | const baseStore = writable(0); 40 | bench( 41 | 'computed', 42 | () => { 43 | computed(() => 2 * baseStore()); 44 | }, 45 | { setup } 46 | ); 47 | 48 | bench( 49 | 'derived', 50 | () => { 51 | derived(baseStore, (a) => 2 * a); 52 | }, 53 | { setup } 54 | ); 55 | 56 | bench( 57 | 'new DoubleStoreClass', 58 | () => { 59 | new DoubleStoreClass(baseStore, 0); 60 | }, 61 | { setup } 62 | ); 63 | }); 64 | 65 | describe('updating derived stores', () => { 66 | const baseStore1 = writable(0); 67 | computed(() => 2 * baseStore1()).subscribe(() => {}); 68 | let count1 = 0; 69 | bench( 70 | 'computed', 71 | () => { 72 | count1++; 73 | baseStore1.set(count1); 74 | }, 75 | { setup } 76 | ); 77 | 78 | const baseStore2 = writable(0); 79 | derived(baseStore2, (a) => 2 * a).subscribe(() => {}); 80 | let count2 = 0; 81 | bench( 82 | 'derived', 83 | () => { 84 | count2++; 85 | baseStore2.set(count2); 86 | }, 87 | { setup } 88 | ); 89 | 90 | const baseStore3 = writable(0); 91 | new DoubleStoreClass(baseStore3, 0).subscribe(() => {}); 92 | let count3 = 0; 93 | bench( 94 | 'DoubleStoreClass', 95 | () => { 96 | count3++; 97 | baseStore3.set(count3); 98 | }, 99 | { setup } 100 | ); 101 | }); 102 | 103 | describe('updating writable stores', () => { 104 | const storeWithoutSubscriber = writable(0); 105 | let count1 = 0; 106 | 107 | bench( 108 | 'without subscriber', 109 | () => { 110 | count1++; 111 | storeWithoutSubscriber.set(count1); 112 | }, 113 | { setup } 114 | ); 115 | 116 | const storeWithSubscriber = writable(0); 117 | storeWithSubscriber.subscribe(() => {}); 118 | let count2 = 0; 119 | bench( 120 | 'with subscriber', 121 | () => { 122 | count2++; 123 | storeWithSubscriber.set(count2); 124 | }, 125 | { setup } 126 | ); 127 | }); 128 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@amadeus-it-group/tansu", 3 | "type": "module", 4 | "version": "2.0.0", 5 | "description": "tansu is a lightweight, push-based framework-agnostic state management library. It borrows the ideas and APIs originally designed and implemented by Svelte stores and extends them with computed and batch.", 6 | "keywords": [ 7 | "signals", 8 | "signal", 9 | "agnostic", 10 | "reactive", 11 | "store", 12 | "state", 13 | "model", 14 | "interop", 15 | "observable", 16 | "computed", 17 | "derived", 18 | "readable", 19 | "writable", 20 | "svelte", 21 | "state management", 22 | "angular" 23 | ], 24 | "typings": "./dist/package/index.d.ts", 25 | "main": "./dist/package/index.cjs", 26 | "module": "./dist/package/index.js", 27 | "exports": { 28 | "types": "./dist/package/index.d.ts", 29 | "require": "./dist/package/index.cjs", 30 | "default": "./dist/package/index.js" 31 | }, 32 | "license": "MIT", 33 | "repository": { 34 | "type": "git", 35 | "url": "git+https://github.com/AmadeusITGroup/tansu.git" 36 | }, 37 | "bugs": { 38 | "url": "https://github.com/AmadeusITGroup/tansu/issues" 39 | }, 40 | "homepage": "https://github.com/AmadeusITGroup/tansu#readme", 41 | "private": true, 42 | "devDependencies": { 43 | "@angular/common": "^19.2.0", 44 | "@angular/compiler": "^19.2.0", 45 | "@angular/compiler-cli": "^19.2.0", 46 | "@angular/core": "^19.2.0", 47 | "@angular/platform-browser": "^19.2.0", 48 | "@angular/platform-browser-dynamic": "^19.2.0", 49 | "@commitlint/cli": "^19.7.1", 50 | "@commitlint/config-conventional": "^19.7.1", 51 | "@eslint/eslintrc": "^3.3.0", 52 | "@eslint/js": "^9.21.0", 53 | "@rollup/plugin-typescript": "^12.1.2", 54 | "@typescript-eslint/eslint-plugin": "^8.26.0", 55 | "@typescript-eslint/parser": "^8.26.0", 56 | "@vitest/coverage-v8": "^3.0.7", 57 | "@vitest/ui": "^3.0.7", 58 | "eslint": "^9.21.0", 59 | "eslint-config-prettier": "^10.0.2", 60 | "happy-dom": "^17.1.9", 61 | "husky": "^9.1.7", 62 | "prettier": "^3.5.3", 63 | "pretty-quick": "^4.0.0", 64 | "rollup": "^4.34.9", 65 | "rxjs": "^7.8.2", 66 | "svelte": "^5.22.1", 67 | "typedoc": "^0.27.9", 68 | "typescript": "^5.8.2", 69 | "vitest": "^3.0.7", 70 | "zone.js": "^0.15.0" 71 | }, 72 | "scripts": { 73 | "test": "vitest run", 74 | "tdd": "vitest", 75 | "tdd:ui": "vitest --ui", 76 | "benchmark": "vitest bench --run", 77 | "clean": "rm -rf dist temp", 78 | "lint": "eslint {src,benchmarks}/{,**/}*.ts", 79 | "build:rollup": "rollup --failAfterWarnings -c", 80 | "build:dts": "tsc -p tsconfig.d.json", 81 | "build": "npm run clean && npm run build:rollup && npm run build:dts", 82 | "prepare": "npm run build", 83 | "format:check": "prettier --check .", 84 | "format:fix": "prettier --write .", 85 | "docs": "typedoc" 86 | }, 87 | "husky": { 88 | "hooks": { 89 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS", 90 | "pre-commit": "pretty-quick --staged" 91 | } 92 | }, 93 | "commitlint": { 94 | "extends": [ 95 | "@commitlint/config-conventional" 96 | ] 97 | }, 98 | "files": [ 99 | "dist/package" 100 | ] 101 | } 102 | -------------------------------------------------------------------------------- /src/internal/storeTrackingUsage.ts: -------------------------------------------------------------------------------- 1 | import { RawStoreFlags } from './store'; 2 | import { checkNotInNotificationPhase, RawStoreWritable } from './storeWritable'; 3 | import { activeConsumer, untrack } from './untrack'; 4 | 5 | let flushUnusedQueue: RawStoreTrackingUsage[] | null = null; 6 | let inFlushUnused = false; 7 | 8 | export const flushUnused = (): void => { 9 | // Ignoring coverage for the following lines because, unless there is a bug in tansu (which would have to be fixed!) 10 | // there should be no way to trigger this error. 11 | /* v8 ignore next 3 */ 12 | if (inFlushUnused) { 13 | throw new Error('assert failed: recursive flushUnused call'); 14 | } 15 | inFlushUnused = true; 16 | try { 17 | const queue = flushUnusedQueue; 18 | if (queue) { 19 | flushUnusedQueue = null; 20 | for (let i = 0, l = queue.length; i < l; i++) { 21 | const producer = queue[i]; 22 | producer.flags &= ~RawStoreFlags.FLUSH_PLANNED; 23 | producer.checkUnused(); 24 | } 25 | } 26 | } finally { 27 | inFlushUnused = false; 28 | } 29 | }; 30 | 31 | export abstract class RawStoreTrackingUsage extends RawStoreWritable { 32 | private extraUsages = 0; 33 | abstract startUse(): void; 34 | abstract endUse(): void; 35 | 36 | override updateValue(): void { 37 | const flags = this.flags; 38 | if (!(flags & RawStoreFlags.START_USE_CALLED)) { 39 | // Ignoring coverage for the following lines because, unless there is a bug in tansu (which would have to be fixed!) 40 | // there should be no way to trigger this error. 41 | /* v8 ignore next 3 */ 42 | if (!this.extraUsages && !this.consumerLinks.length) { 43 | throw new Error('assert failed: untracked producer usage'); 44 | } 45 | this.flags |= RawStoreFlags.START_USE_CALLED; 46 | untrack(() => this.startUse()); 47 | } 48 | } 49 | 50 | override checkUnused(): void { 51 | const flags = this.flags; 52 | if (flags & RawStoreFlags.START_USE_CALLED && !this.extraUsages && !this.consumerLinks.length) { 53 | if (inFlushUnused || flags & RawStoreFlags.HAS_VISIBLE_ONUSE) { 54 | this.flags &= ~RawStoreFlags.START_USE_CALLED; 55 | untrack(() => this.endUse()); 56 | } else if (!(flags & RawStoreFlags.FLUSH_PLANNED)) { 57 | this.flags |= RawStoreFlags.FLUSH_PLANNED; 58 | if (!flushUnusedQueue) { 59 | flushUnusedQueue = []; 60 | queueMicrotask(flushUnused); 61 | } 62 | flushUnusedQueue.push(this); 63 | } 64 | } 65 | } 66 | 67 | override get(): T { 68 | checkNotInNotificationPhase(); 69 | if (activeConsumer) { 70 | return activeConsumer.addProducer(this); 71 | } else { 72 | this.extraUsages++; 73 | try { 74 | this.updateValue(); 75 | // Ignoring coverage for the following lines because, unless there is a bug in tansu (which would have to be fixed!) 76 | // there should be no way to trigger this error. 77 | /* v8 ignore next 3 */ 78 | if (this.flags & RawStoreFlags.DIRTY) { 79 | throw new Error('assert failed: store still dirty after updating it'); 80 | } 81 | return this.readValue(); 82 | } finally { 83 | const extraUsages = --this.extraUsages; 84 | if (extraUsages === 0) { 85 | this.checkUnused(); 86 | } 87 | } 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/internal/storeComputed.ts: -------------------------------------------------------------------------------- 1 | import type { BaseLink, Consumer, RawStore } from './store'; 2 | import { RawStoreFlags, updateLinkProducerValue } from './store'; 3 | import { 4 | COMPUTED_ERRORED, 5 | COMPUTED_UNSET, 6 | RawStoreComputedOrDerived, 7 | } from './storeComputedOrDerived'; 8 | import { epoch, notificationPhase } from './storeWritable'; 9 | import { activeConsumer, setActiveConsumer, type ActiveConsumer } from './untrack'; 10 | 11 | export class RawStoreComputed 12 | extends RawStoreComputedOrDerived 13 | implements Consumer, ActiveConsumer 14 | { 15 | private producerIndex = 0; 16 | private producerLinks: BaseLink[] = []; 17 | private epoch = -1; 18 | 19 | constructor(private readonly computeFn: () => T) { 20 | super(COMPUTED_UNSET); 21 | } 22 | 23 | override increaseEpoch(): void { 24 | // do nothing 25 | } 26 | 27 | override updateValue(): void { 28 | const flags = this.flags; 29 | if (flags & RawStoreFlags.START_USE_CALLED && this.epoch === epoch) { 30 | return; 31 | } 32 | super.updateValue(); 33 | this.epoch = epoch; 34 | } 35 | 36 | override get(): T { 37 | if ( 38 | !activeConsumer && 39 | !notificationPhase && 40 | this.epoch === epoch && 41 | (!(this.flags & RawStoreFlags.HAS_VISIBLE_ONUSE) || 42 | this.flags & RawStoreFlags.START_USE_CALLED) 43 | ) { 44 | return this.readValue(); 45 | } 46 | return super.get(); 47 | } 48 | 49 | addProducer>(producer: RawStore): U { 50 | const producerLinks = this.producerLinks; 51 | const producerIndex = this.producerIndex; 52 | let link = producerLinks[producerIndex] as L | undefined; 53 | if (link?.producer !== producer) { 54 | if (link) { 55 | producerLinks.push(link); // push the existing link at the end (to be removed later) 56 | } 57 | link = producer.registerConsumer(producer.newLink(this)); 58 | } 59 | producerLinks[producerIndex] = link; 60 | this.producerIndex = producerIndex + 1; 61 | updateLinkProducerValue(link); 62 | if (producer.flags & RawStoreFlags.HAS_VISIBLE_ONUSE) { 63 | this.flags |= RawStoreFlags.HAS_VISIBLE_ONUSE; 64 | } 65 | return producer.updateLink(link); 66 | } 67 | 68 | override startUse(): void { 69 | const producerLinks = this.producerLinks; 70 | for (let i = 0, l = producerLinks.length; i < l; i++) { 71 | const link = producerLinks[i]; 72 | link.producer.registerConsumer(link); 73 | } 74 | this.flags |= RawStoreFlags.DIRTY; 75 | } 76 | 77 | override endUse(): void { 78 | const producerLinks = this.producerLinks; 79 | for (let i = 0, l = producerLinks.length; i < l; i++) { 80 | const link = producerLinks[i]; 81 | link.producer.unregisterConsumer(link); 82 | } 83 | } 84 | 85 | override areProducersUpToDate(): boolean { 86 | if (this.value === COMPUTED_UNSET) { 87 | return false; 88 | } 89 | const producerLinks = this.producerLinks; 90 | for (let i = 0, l = producerLinks.length; i < l; i++) { 91 | const link = producerLinks[i]; 92 | const producer = link.producer; 93 | updateLinkProducerValue(link); 94 | if (!producer.isLinkUpToDate(link)) { 95 | return false; 96 | } 97 | } 98 | return true; 99 | } 100 | 101 | override recompute(): void { 102 | let value: T; 103 | const prevActiveConsumer = setActiveConsumer(this); 104 | try { 105 | this.producerIndex = 0; 106 | this.flags &= ~RawStoreFlags.HAS_VISIBLE_ONUSE; 107 | const computeFn = this.computeFn; 108 | value = computeFn(); 109 | this.error = null; 110 | } catch (error) { 111 | value = COMPUTED_ERRORED; 112 | this.error = error; 113 | } finally { 114 | setActiveConsumer(prevActiveConsumer); 115 | } 116 | // Remove unused producers: 117 | const producerLinks = this.producerLinks; 118 | const producerIndex = this.producerIndex; 119 | if (producerIndex < producerLinks.length) { 120 | for (let i = 0, l = producerLinks.length - producerIndex; i < l; i++) { 121 | const link = producerLinks.pop()!; 122 | link.producer.unregisterConsumer(link); 123 | } 124 | } 125 | this.set(value); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/internal/storeDerived.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AsyncDeriveFn, 3 | OnUseArgument, 4 | StoreInput, 5 | StoresInput, 6 | StoresInputValues, 7 | SyncDeriveFn, 8 | UnsubscribeFunction, 9 | Unsubscriber, 10 | } from '../types'; 11 | import { getRawStore } from './exposeRawStores'; 12 | import type { BaseLink, Consumer, RawStore } from './store'; 13 | import { RawStoreFlags, updateLinkProducerValue } from './store'; 14 | import { 15 | COMPUTED_ERRORED, 16 | COMPUTED_UNSET, 17 | RawStoreComputedOrDerived, 18 | } from './storeComputedOrDerived'; 19 | import type { RawStoreWritable } from './storeWritable'; 20 | import { normalizeUnsubscribe } from './unsubscribe'; 21 | 22 | abstract class RawStoreDerived 23 | extends RawStoreComputedOrDerived 24 | implements Consumer 25 | { 26 | private readonly arrayMode: boolean; 27 | private readonly producers: RawStore[]; 28 | private producerLinks: BaseLink[] | null = null; 29 | private cleanUpFn: UnsubscribeFunction | null = null; 30 | override flags = RawStoreFlags.HAS_VISIBLE_ONUSE | RawStoreFlags.DIRTY; 31 | 32 | constructor(producers: S, initialValue: T) { 33 | super(initialValue); 34 | const arrayMode = Array.isArray(producers); 35 | this.arrayMode = arrayMode; 36 | this.producers = ( 37 | arrayMode ? (producers as StoreInput[]) : [producers as StoreInput] 38 | ).map(getRawStore); 39 | } 40 | 41 | callCleanUpFn(): void { 42 | const cleanUpFn = this.cleanUpFn; 43 | if (cleanUpFn) { 44 | this.cleanUpFn = null; 45 | cleanUpFn(); 46 | } 47 | } 48 | 49 | override startUse(): void { 50 | this.producerLinks = this.producers.map((producer) => 51 | producer.registerConsumer(producer.newLink(this)) 52 | ); 53 | this.flags |= RawStoreFlags.DIRTY; 54 | } 55 | 56 | override endUse(): void { 57 | this.callCleanUpFn(); 58 | const producerLinks = this.producerLinks; 59 | this.producerLinks = null; 60 | if (producerLinks) { 61 | for (let i = 0, l = producerLinks.length; i < l; i++) { 62 | const link = producerLinks[i]; 63 | link.producer.unregisterConsumer(link); 64 | } 65 | } 66 | } 67 | 68 | override areProducersUpToDate(): boolean { 69 | const producerLinks = this.producerLinks!; 70 | let alreadyUpToDate = this.value !== COMPUTED_UNSET; 71 | for (let i = 0, l = producerLinks.length; i < l; i++) { 72 | const link = producerLinks[i]; 73 | const producer = link.producer; 74 | updateLinkProducerValue(link); 75 | if (!producer.isLinkUpToDate(link)) { 76 | alreadyUpToDate = false; 77 | } 78 | } 79 | return alreadyUpToDate; 80 | } 81 | 82 | override recompute(): void { 83 | try { 84 | this.callCleanUpFn(); 85 | const values = this.producerLinks!.map((link) => link.producer.updateLink(link)); 86 | this.cleanUpFn = normalizeUnsubscribe(this.derive(this.arrayMode ? values : values[0])); 87 | } catch (error) { 88 | this.error = error; 89 | this.set(COMPUTED_ERRORED); 90 | } 91 | } 92 | 93 | protected abstract derive(values: S): void; 94 | } 95 | 96 | export class RawStoreDerivedStore extends RawStoreDerived { 97 | constructor( 98 | stores: S, 99 | initialValue: T, 100 | protected readonly derive: (values: StoresInputValues) => void 101 | ) { 102 | super(stores, initialValue); 103 | } 104 | } 105 | 106 | export class RawStoreSyncDerived extends RawStoreDerived { 107 | constructor( 108 | stores: S, 109 | _initialValue: T, 110 | private readonly deriveFn: SyncDeriveFn 111 | ) { 112 | super(stores, COMPUTED_UNSET); 113 | } 114 | protected override derive(values: StoresInputValues): void { 115 | const deriveFn = this.deriveFn; 116 | this.set(deriveFn(values)); 117 | } 118 | } 119 | 120 | export const createOnUseArg = (store: RawStoreWritable): OnUseArgument => { 121 | const setFn = store.set.bind(store) as any; 122 | setFn.set = setFn; 123 | setFn.update = store.update.bind(store); 124 | return setFn; 125 | }; 126 | 127 | export class RawStoreAsyncDerived extends RawStoreDerived { 128 | private readonly setFn = createOnUseArg(this); 129 | constructor( 130 | stores: S, 131 | initialValue: T, 132 | private readonly deriveFn: AsyncDeriveFn 133 | ) { 134 | super(stores, initialValue); 135 | } 136 | protected override derive(values: StoresInputValues): Unsubscriber | void { 137 | const deriveFn = this.deriveFn; 138 | return deriveFn(values, this.setFn); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/internal/storeWritable.ts: -------------------------------------------------------------------------------- 1 | import type { Subscriber, UnsubscribeFunction, UnsubscribeObject, Updater } from '../types'; 2 | import { batch } from './batch'; 3 | import { equal } from './equal'; 4 | import type { Consumer, RawStore } from './store'; 5 | import { RawStoreFlags } from './store'; 6 | import { SubscribeConsumer } from './subscribeConsumer'; 7 | import { activeConsumer } from './untrack'; 8 | 9 | export let notificationPhase = false; 10 | 11 | export const checkNotInNotificationPhase = (): void => { 12 | if (notificationPhase) { 13 | throw new Error('Reading or writing a signal is forbidden during the notification phase.'); 14 | } 15 | }; 16 | 17 | export let epoch = 0; 18 | 19 | export interface ProducerConsumerLink { 20 | value: T; 21 | version: number; 22 | producer: RawStore>; 23 | indexInProducer: number; 24 | consumer: Consumer; 25 | skipMarkDirty: boolean; 26 | } 27 | 28 | export class RawStoreWritable implements RawStore> { 29 | constructor(protected value: T) {} 30 | flags = RawStoreFlags.NONE; 31 | private version = 0; 32 | equalFn = equal; 33 | private equalCache: Record | null = null; 34 | consumerLinks: ProducerConsumerLink[] = []; 35 | 36 | newLink(consumer: Consumer): ProducerConsumerLink { 37 | return { 38 | version: -1, 39 | value: undefined as any, 40 | producer: this, 41 | indexInProducer: 0, 42 | consumer, 43 | skipMarkDirty: false, 44 | }; 45 | } 46 | 47 | isLinkUpToDate(link: ProducerConsumerLink): boolean { 48 | if (link.version === this.version) { 49 | return true; 50 | } 51 | if (link.version === this.version - 1 || link.version < 0) { 52 | return false; 53 | } 54 | let equalCache = this.equalCache; 55 | if (!equalCache) { 56 | equalCache = {}; 57 | this.equalCache = equalCache; 58 | } 59 | let res = equalCache[link.version]; 60 | if (res === undefined) { 61 | res = this.equal(link.value, this.value); 62 | equalCache[link.version] = res; 63 | } 64 | return res; 65 | } 66 | 67 | updateLink(link: ProducerConsumerLink): T { 68 | link.value = this.value; 69 | link.version = this.version; 70 | return this.readValue(); 71 | } 72 | 73 | registerConsumer(link: ProducerConsumerLink): ProducerConsumerLink { 74 | const consumerLinks = this.consumerLinks; 75 | const indexInProducer = consumerLinks.length; 76 | link.indexInProducer = indexInProducer; 77 | consumerLinks[indexInProducer] = link; 78 | return link; 79 | } 80 | 81 | unregisterConsumer(link: ProducerConsumerLink): void { 82 | const consumerLinks = this.consumerLinks; 83 | const index = link.indexInProducer; 84 | // Ignoring coverage for the following lines because, unless there is a bug in tansu (which would have to be fixed!) 85 | // there should be no way to trigger this error. 86 | /* v8 ignore next 3 */ 87 | if (consumerLinks[index] !== link) { 88 | throw new Error('assert failed: invalid indexInProducer'); 89 | } 90 | // swap with the last item to avoid shifting the array 91 | const lastConsumerLink = consumerLinks.pop()!; 92 | const isLast = link === lastConsumerLink; 93 | if (!isLast) { 94 | consumerLinks[index] = lastConsumerLink; 95 | lastConsumerLink.indexInProducer = index; 96 | } else if (index === 0) { 97 | this.checkUnused(); 98 | } 99 | } 100 | 101 | protected checkUnused(): void {} 102 | updateValue(): void {} 103 | 104 | protected equal(a: T, b: T): boolean { 105 | const equalFn = this.equalFn; 106 | return equalFn(a, b); 107 | } 108 | 109 | protected increaseEpoch(): void { 110 | epoch++; 111 | this.markConsumersDirty(); 112 | } 113 | 114 | set(newValue: T): void { 115 | checkNotInNotificationPhase(); 116 | const same = this.equal(this.value, newValue); 117 | if (!same) { 118 | batch(() => { 119 | this.value = newValue; 120 | this.version++; 121 | this.equalCache = null; 122 | this.increaseEpoch(); 123 | }); 124 | } 125 | } 126 | 127 | update(updater: Updater): void { 128 | this.set(updater(this.value)); 129 | } 130 | 131 | protected markConsumersDirty(): void { 132 | const prevNotificationPhase = notificationPhase; 133 | notificationPhase = true; 134 | try { 135 | const consumerLinks = this.consumerLinks; 136 | for (let i = 0, l = consumerLinks.length; i < l; i++) { 137 | const link = consumerLinks[i]; 138 | if (link.skipMarkDirty) continue; 139 | link.consumer.markDirty(); 140 | } 141 | } finally { 142 | notificationPhase = prevNotificationPhase; 143 | } 144 | } 145 | 146 | get(): T { 147 | checkNotInNotificationPhase(); 148 | return activeConsumer ? activeConsumer.addProducer(this) : this.readValue(); 149 | } 150 | 151 | readValue(): T { 152 | return this.value; 153 | } 154 | 155 | subscribe(subscriber: Subscriber): UnsubscribeFunction & UnsubscribeObject { 156 | checkNotInNotificationPhase(); 157 | const subscription = new SubscribeConsumer(this, subscriber); 158 | const unsubscriber = () => subscription.unsubscribe(); 159 | unsubscriber.unsubscribe = unsubscriber; 160 | return unsubscriber; 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /benchmarks/js-reactivity-benchmarks/sBench.bench.ts: -------------------------------------------------------------------------------- 1 | // adapted from https://github.com/milomg/js-reactivity-benchmark/blob/main/src/sBench.ts 2 | 3 | import { bench } from 'vitest'; 4 | import type { ReadableSignal, WritableSignal } from '../../src'; 5 | import { computed, writable } from '../../src'; 6 | import { setup } from '../gc'; 7 | 8 | // Inspired by https://github.com/solidjs/solid/blob/main/packages/solid/bench/bench.cjs 9 | 10 | const COUNT = 1e4; 11 | 12 | type Reader = () => number; 13 | 14 | defineBench(onlyCreateDataSignals, COUNT, COUNT); 15 | defineBench(createComputations0to1, COUNT, 0); 16 | defineBench(createComputations1to1, COUNT, COUNT); 17 | defineBench(createComputations2to1, COUNT / 2, COUNT); 18 | defineBench(createComputations4to1, COUNT / 4, COUNT); 19 | defineBench(createComputations1000to1, COUNT / 1000, COUNT); 20 | // createTotal += bench(createComputations8to1, COUNT, 8 * COUNT); 21 | defineBench(createComputations1to2, COUNT, COUNT / 2); 22 | defineBench(createComputations1to4, COUNT, COUNT / 4); 23 | defineBench(createComputations1to8, COUNT, COUNT / 8); 24 | defineBench(createComputations1to1000, COUNT, COUNT / 1000); 25 | defineBench(updateComputations1to1, COUNT * 4, 1); 26 | defineBench(updateComputations2to1, COUNT * 2, 2); 27 | defineBench(updateComputations4to1, COUNT, 4); 28 | defineBench(updateComputations1000to1, COUNT / 100, 1000); 29 | defineBench(updateComputations1to2, COUNT * 4, 1); 30 | defineBench(updateComputations1to4, COUNT * 4, 1); 31 | defineBench(updateComputations1to1000, COUNT * 4, 1); 32 | 33 | function defineBench(fn: (n: number, sources: any[]) => void, n: number, scount: number) { 34 | bench(fn.name, () => fn(n, createDataSignals(scount, [])), { throws: true, setup }); 35 | } 36 | 37 | function onlyCreateDataSignals() { 38 | // createDataSignals is already called before 39 | } 40 | 41 | function createDataSignals(n: number, sources: ReadableSignal[]) { 42 | for (let i = 0; i < n; i++) { 43 | sources[i] = writable(i); 44 | } 45 | return sources; 46 | } 47 | 48 | function createComputations0to1(n: number /*, _sources: ReadableSignal[]*/) { 49 | for (let i = 0; i < n; i++) { 50 | createComputation0(i); 51 | } 52 | } 53 | 54 | function createComputations1to1000(n: number, sources: ReadableSignal[]) { 55 | for (let i = 0; i < n / 1000; i++) { 56 | const get = sources[i]; 57 | for (let j = 0; j < 1000; j++) { 58 | createComputation1(get); 59 | } 60 | } 61 | } 62 | 63 | function createComputations1to8(n: number, sources: ReadableSignal[]) { 64 | for (let i = 0; i < n / 8; i++) { 65 | const get = sources[i]; 66 | createComputation1(get); 67 | createComputation1(get); 68 | createComputation1(get); 69 | createComputation1(get); 70 | createComputation1(get); 71 | createComputation1(get); 72 | createComputation1(get); 73 | createComputation1(get); 74 | } 75 | } 76 | 77 | function createComputations1to4(n: number, sources: ReadableSignal[]) { 78 | for (let i = 0; i < n / 4; i++) { 79 | const get = sources[i]; 80 | createComputation1(get); 81 | createComputation1(get); 82 | createComputation1(get); 83 | createComputation1(get); 84 | } 85 | } 86 | 87 | function createComputations1to2(n: number, sources: ReadableSignal[]) { 88 | for (let i = 0; i < n / 2; i++) { 89 | const get = sources[i]; 90 | createComputation1(get); 91 | createComputation1(get); 92 | } 93 | } 94 | 95 | function createComputations1to1(n: number, sources: ReadableSignal[]) { 96 | for (let i = 0; i < n; i++) { 97 | const get = sources[i]; 98 | createComputation1(get); 99 | } 100 | } 101 | 102 | function createComputations2to1(n: number, sources: ReadableSignal[]) { 103 | for (let i = 0; i < n; i++) { 104 | createComputation2(sources[i * 2], sources[i * 2 + 1]); 105 | } 106 | } 107 | 108 | function createComputations4to1(n: number, sources: ReadableSignal[]) { 109 | for (let i = 0; i < n; i++) { 110 | createComputation4(sources[i * 4], sources[i * 4 + 1], sources[i * 4 + 2], sources[i * 4 + 3]); 111 | } 112 | } 113 | 114 | // function createComputations8to1(n: number, sources: ReadableSignal[]) { 115 | // for (let i = 0; i < n; i++) { 116 | // createComputation8( 117 | // sources[i * 8], 118 | // sources[i * 8 + 1], 119 | // sources[i * 8 + 2], 120 | // sources[i * 8 + 3], 121 | // sources[i * 8 + 4], 122 | // sources[i * 8 + 5], 123 | // sources[i * 8 + 6], 124 | // sources[i * 8 + 7] 125 | // ); 126 | // } 127 | // } 128 | 129 | // only create n / 100 computations, as otherwise takes too long 130 | function createComputations1000to1(n: number, sources: ReadableSignal[]) { 131 | for (let i = 0; i < n; i++) { 132 | createComputation1000(sources, i * 1000); 133 | } 134 | } 135 | 136 | function createComputation0(i: number) { 137 | computed(() => i).subscribe(() => {}); 138 | } 139 | 140 | function createComputation1(s1: Reader) { 141 | computed(() => s1()).subscribe(() => {}); 142 | } 143 | function createComputation2(s1: Reader, s2: Reader) { 144 | computed(() => s1() + s2()).subscribe(() => {}); 145 | } 146 | 147 | function createComputation4(s1: Reader, s2: Reader, s3: Reader, s4: Reader) { 148 | computed(() => s1() + s2() + s3() + s4()).subscribe(() => {}); 149 | } 150 | 151 | // function createComputation8( 152 | // s1: Reader, 153 | // s2: Reader, 154 | // s3: Reader, 155 | // s4: Reader, 156 | // s5: Reader, 157 | // s6: Reader, 158 | // s7: Reader, 159 | // s8: Reader 160 | // ) { 161 | // computed( 162 | // () => s1() + s2() + s3() + s4() + s5() + s6() + s7() + s8() 163 | // ); 164 | // } 165 | 166 | function createComputation1000(ss: ReadableSignal[], offset: number) { 167 | computed(() => { 168 | let sum = 0; 169 | for (let i = 0; i < 1000; i++) { 170 | sum += ss[offset + i](); 171 | } 172 | return sum; 173 | }).subscribe(() => {}); 174 | } 175 | 176 | function updateComputations1to1(n: number, sources: WritableSignal[]) { 177 | const get1 = sources[0]; 178 | const set1 = get1.set; 179 | computed(() => get1()).subscribe(() => {}); 180 | for (let i = 0; i < n; i++) { 181 | set1(i); 182 | } 183 | } 184 | 185 | function updateComputations2to1(n: number, sources: WritableSignal[]) { 186 | const get1 = sources[0], 187 | set1 = get1.set, 188 | get2 = sources[1]; 189 | computed(() => get1() + get2()).subscribe(() => {}); 190 | for (let i = 0; i < n; i++) { 191 | set1(i); 192 | } 193 | } 194 | 195 | function updateComputations4to1(n: number, sources: WritableSignal[]) { 196 | const get1 = sources[0], 197 | set1 = get1.set, 198 | get2 = sources[1], 199 | get3 = sources[2], 200 | get4 = sources[3]; 201 | computed(() => get1() + get2() + get3() + get4()).subscribe(() => {}); 202 | for (let i = 0; i < n; i++) { 203 | set1(i); 204 | } 205 | } 206 | 207 | function updateComputations1000to1(n: number, sources: WritableSignal[]) { 208 | const { set: set1 } = sources[0]; 209 | computed(() => { 210 | let sum = 0; 211 | for (let i = 0; i < 1000; i++) { 212 | sum += sources[i](); 213 | } 214 | return sum; 215 | }).subscribe(() => {}); 216 | for (let i = 0; i < n; i++) { 217 | set1(i); 218 | } 219 | } 220 | 221 | function updateComputations1to2(n: number, sources: WritableSignal[]) { 222 | const get1 = sources[0]; 223 | const set1 = get1.set; 224 | computed(() => get1()).subscribe(() => {}); 225 | computed(() => get1()).subscribe(() => {}); 226 | for (let i = 0; i < n / 2; i++) { 227 | set1(i); 228 | } 229 | } 230 | 231 | function updateComputations1to4(n: number, sources: WritableSignal[]) { 232 | const get1 = sources[0]; 233 | const set1 = get1.set; 234 | computed(() => get1()).subscribe(() => {}); 235 | computed(() => get1()).subscribe(() => {}); 236 | computed(() => get1()).subscribe(() => {}); 237 | computed(() => get1()).subscribe(() => {}); 238 | for (let i = 0; i < n / 4; i++) { 239 | set1(i); 240 | } 241 | } 242 | 243 | function updateComputations1to1000(n: number, sources: WritableSignal[]) { 244 | const get1 = sources[0]; 245 | const set1 = get1.set; 246 | for (let i = 0; i < 1000; i++) { 247 | computed(() => get1()).subscribe(() => {}); 248 | } 249 | for (let i = 0; i < n / 1000; i++) { 250 | set1(i); 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /benchmarks/js-reactivity-benchmarks/dynamic.bench.ts: -------------------------------------------------------------------------------- 1 | // adapted from https://github.com/milomg/js-reactivity-benchmark/blob/main/src/dynamicBench.ts 2 | 3 | import { bench, expect } from 'vitest'; 4 | import type { ReadableSignal, WritableSignal } from '../../src'; 5 | import { computed, writable } from '../../src'; 6 | import { setup } from '../gc'; 7 | 8 | // from https://github.com/milomg/js-reactivity-benchmark/blob/main/src/util/pseudoRandom.ts 9 | 10 | export function pseudoRandom(seed = 'seed'): () => number { 11 | const hash = xmur3a(seed); 12 | const rng = sfc32(hash(), hash(), hash(), hash()); 13 | return rng; 14 | } 15 | 16 | /* these are adapted from https://github.com/bryc/code/blob/master/jshash/PRNGs.md 17 | * (License: Public domain) */ 18 | 19 | /** random number generator originally in PractRand */ 20 | function sfc32(a: number, b: number, c: number, d: number): () => number { 21 | return function () { 22 | a >>>= 0; 23 | b >>>= 0; 24 | c >>>= 0; 25 | d >>>= 0; 26 | let t = (a + b) | 0; 27 | a = b ^ (b >>> 9); 28 | b = (c + (c << 3)) | 0; 29 | c = (c << 21) | (c >>> 11); 30 | d = (d + 1) | 0; 31 | t = (t + d) | 0; 32 | c = (c + t) | 0; 33 | return (t >>> 0) / 4294967296; 34 | }; 35 | } 36 | 37 | /** MurmurHash3 */ 38 | export function xmur3a(str: string): () => number { 39 | let h = 2166136261 >>> 0; 40 | for (let k: number, i = 0; i < str.length; i++) { 41 | k = Math.imul(str.charCodeAt(i), 3432918353); 42 | k = (k << 15) | (k >>> 17); 43 | h ^= Math.imul(k, 461845907); 44 | h = (h << 13) | (h >>> 19); 45 | h = (Math.imul(h, 5) + 3864292196) | 0; 46 | } 47 | h ^= str.length; 48 | return function () { 49 | h ^= h >>> 16; 50 | h = Math.imul(h, 2246822507); 51 | h ^= h >>> 13; 52 | h = Math.imul(h, 3266489909); 53 | h ^= h >>> 16; 54 | return h >>> 0; 55 | }; 56 | } 57 | 58 | // from https://github.com/milomg/js-reactivity-benchmark/blob/main/src/util/perfTests.ts 59 | 60 | export interface TestResult { 61 | sum: number; 62 | count: number; 63 | } 64 | 65 | // from https://github.com/milomg/js-reactivity-benchmark/blob/main/src/util/frameworkTypes.ts 66 | 67 | interface TestConfig { 68 | /** friendly name for the test, should be unique */ 69 | name?: string; 70 | 71 | /** width of dependency graph to construct */ 72 | width: number; 73 | 74 | /** depth of dependency graph to construct */ 75 | totalLayers: number; 76 | 77 | /** fraction of nodes that are static */ // TODO change to dynamicFraction 78 | staticFraction: number; 79 | 80 | /** construct a graph with number of sources in each node */ 81 | nSources: number; 82 | 83 | /** fraction of [0, 1] elements in the last layer from which to read values in each test iteration */ 84 | readFraction: number; 85 | 86 | /** number of test iterations */ 87 | iterations: number; 88 | 89 | /** sum and count of all iterations, for verification */ 90 | expected: Partial; 91 | } 92 | 93 | // from https://github.com/milomg/js-reactivity-benchmark/blob/main/src/util/dependencyGraph.ts 94 | 95 | interface Graph { 96 | sources: WritableSignal[]; 97 | layers: ReadableSignal[][]; 98 | } 99 | 100 | interface GraphAndCounter { 101 | graph: Graph; 102 | counter: Counter; 103 | } 104 | 105 | /** 106 | * Make a rectangular dependency graph, with an equal number of source elements 107 | * and computation elements at every layer. 108 | * 109 | * @param width number of source elements and number of computed elements per layer 110 | * @param totalLayers total number of source and computed layers 111 | * @param staticFraction every nth computed node is static (1 = all static, 3 = 2/3rd are dynamic) 112 | * @returns the graph 113 | */ 114 | function makeGraph(config: TestConfig): GraphAndCounter { 115 | const { width, totalLayers, staticFraction, nSources } = config; 116 | 117 | const sources = new Array(width).fill(0).map((_, i) => writable(i)); 118 | const counter = new Counter(); 119 | const rows = makeDependentRows(sources, totalLayers - 1, counter, staticFraction, nSources); 120 | const graph = { sources, layers: rows }; 121 | return { graph, counter }; 122 | } 123 | 124 | /** 125 | * Execute the graph by writing one of the sources and reading some or all of the leaves. 126 | * 127 | * @return the sum of all leaf values 128 | */ 129 | function runGraph(graph: Graph, iterations: number, readFraction: number): number { 130 | const rand = pseudoRandom(); 131 | const { sources, layers } = graph; 132 | const leaves = layers[layers.length - 1]; 133 | const skipCount = Math.round(leaves.length * (1 - readFraction)); 134 | const readLeaves = removeElems(leaves, skipCount, rand); 135 | 136 | for (let i = 0; i < iterations; i++) { 137 | const sourceDex = i % sources.length; 138 | sources[sourceDex].set(i + sourceDex); 139 | for (const leaf of readLeaves) { 140 | leaf(); 141 | } 142 | } 143 | 144 | const sum = readLeaves.reduce((total, leaf) => leaf() + total, 0); 145 | return sum; 146 | } 147 | 148 | function removeElems(src: T[], rmCount: number, rand: () => number): T[] { 149 | const copy = src.slice(); 150 | for (let i = 0; i < rmCount; i++) { 151 | const rmDex = Math.floor(rand() * copy.length); 152 | copy.splice(rmDex, 1); 153 | } 154 | return copy; 155 | } 156 | 157 | class Counter { 158 | count = 0; 159 | } 160 | 161 | function makeDependentRows( 162 | sources: ReadableSignal[], 163 | numRows: number, 164 | counter: Counter, 165 | staticFraction: number, 166 | nSources: number 167 | ): ReadableSignal[][] { 168 | let prevRow = sources; 169 | const random = pseudoRandom(); 170 | const rows = []; 171 | for (let l = 0; l < numRows; l++) { 172 | const row = makeRow(prevRow, counter, staticFraction, nSources, l, random); 173 | rows.push(row); 174 | prevRow = row; 175 | } 176 | return rows; 177 | } 178 | 179 | function makeRow( 180 | sources: ReadableSignal[], 181 | counter: Counter, 182 | staticFraction: number, 183 | nSources: number, 184 | layer: number, 185 | random: () => number 186 | ): ReadableSignal[] { 187 | return sources.map((_, myDex) => { 188 | const mySources: ReadableSignal[] = []; 189 | for (let sourceDex = 0; sourceDex < nSources; sourceDex++) { 190 | mySources.push(sources[(myDex + sourceDex) % sources.length]); 191 | } 192 | 193 | const staticNode = random() < staticFraction; 194 | if (staticNode) { 195 | // static node, always reference sources 196 | return computed(() => { 197 | counter.count++; 198 | 199 | let sum = 0; 200 | for (const src of mySources) { 201 | sum += src(); 202 | } 203 | return sum; 204 | }); 205 | } else { 206 | // dynamic node, drops one of the sources depending on the value of the first element 207 | const first = mySources[0]; 208 | const tail = mySources.slice(1); 209 | const node = computed(() => { 210 | counter.count++; 211 | let sum = first(); 212 | const shouldDrop = sum & 0x1; 213 | const dropDex = sum % tail.length; 214 | 215 | for (let i = 0; i < tail.length; i++) { 216 | if (shouldDrop && i === dropDex) continue; 217 | sum += tail[i](); 218 | } 219 | 220 | return sum; 221 | }); 222 | return node; 223 | } 224 | }); 225 | } 226 | 227 | // cf https://github.com/milomg/js-reactivity-benchmark/blob/main/src/config.ts 228 | const perfTests = [ 229 | { 230 | name: 'simple component', 231 | width: 10, // can't change for decorator tests 232 | staticFraction: 1, // can't change for decorator tests 233 | nSources: 2, // can't change for decorator tests 234 | totalLayers: 5, 235 | readFraction: 0.2, 236 | iterations: 600000, 237 | expected: { 238 | sum: 19199968, 239 | count: 3480000, 240 | }, 241 | }, 242 | { 243 | name: 'dynamic component', 244 | width: 10, 245 | totalLayers: 10, 246 | staticFraction: 3 / 4, 247 | nSources: 6, 248 | readFraction: 0.2, 249 | iterations: 15000, 250 | expected: { 251 | sum: 302310782860, 252 | count: 1155000, 253 | }, 254 | }, 255 | { 256 | name: 'large web app', 257 | width: 1000, 258 | totalLayers: 12, 259 | staticFraction: 0.95, 260 | nSources: 4, 261 | readFraction: 1, 262 | iterations: 7000, 263 | expected: { 264 | sum: 29355933696000, 265 | count: 1463000, 266 | }, 267 | }, 268 | { 269 | name: 'wide dense', 270 | width: 1000, 271 | totalLayers: 5, 272 | staticFraction: 1, 273 | nSources: 25, 274 | readFraction: 1, 275 | iterations: 3000, 276 | expected: { 277 | sum: 1171484375000, 278 | count: 732000, 279 | }, 280 | }, 281 | { 282 | name: 'deep', 283 | width: 5, 284 | totalLayers: 500, 285 | staticFraction: 1, 286 | nSources: 3, 287 | readFraction: 1, 288 | iterations: 500, 289 | expected: { 290 | sum: 3.0239642676898464e241, 291 | count: 1246500, 292 | }, 293 | }, 294 | { 295 | name: 'very dynamic', 296 | width: 100, 297 | totalLayers: 15, 298 | staticFraction: 0.5, 299 | nSources: 6, 300 | readFraction: 1, 301 | iterations: 2000, 302 | expected: { 303 | sum: 15664996402790400, 304 | count: 1078000, 305 | }, 306 | }, 307 | ]; 308 | 309 | for (const config of perfTests) { 310 | let graphAndCounter: GraphAndCounter; 311 | 312 | bench( 313 | `dynamic ${config.name}`, 314 | () => { 315 | const { graph, counter } = graphAndCounter; 316 | counter.count = 0; 317 | const sum = runGraph(graph, config.iterations, config.readFraction); 318 | 319 | if (config.expected.sum) { 320 | expect(sum).toBe(config.expected.sum); 321 | } 322 | if (config.expected.count) { 323 | expect(counter.count).toBe(config.expected.count); 324 | } 325 | }, 326 | { 327 | throws: true, 328 | time: 5000, 329 | setup() { 330 | graphAndCounter = makeGraph(config); 331 | setup(); 332 | }, 333 | } 334 | ); 335 | } 336 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface SymbolConstructor { 3 | readonly observable: symbol; 4 | } 5 | } 6 | 7 | /** 8 | * A callback invoked when a store value changes. It is called with the latest value of a given store. 9 | */ 10 | export type SubscriberFunction = ((value: T) => void) & 11 | Partial, 'next'>>; 12 | 13 | /** 14 | * A partial {@link https://github.com/tc39/proposal-observable#api | observer} notified when a store value changes. A store will call the {@link SubscriberObject.next | next} method every time the store's state is changing. 15 | */ 16 | export interface SubscriberObject { 17 | /** 18 | * A store will call this method every time the store's state is changing. 19 | */ 20 | next: SubscriberFunction; 21 | /** 22 | * Unused, only declared for compatibility with rxjs. 23 | */ 24 | error?: any; 25 | /** 26 | * Unused, only declared for compatibility with rxjs. 27 | */ 28 | complete?: any; 29 | /** 30 | * A store will call this method when it knows that the value will be changed. 31 | * A call to this method will be followed by a call to {@link SubscriberObject.next | next} or to {@link SubscriberObject.resume | resume}. 32 | */ 33 | pause: () => void; 34 | /** 35 | * A store will call this method if {@link SubscriberObject.pause | pause} was called previously 36 | * and the value finally did not need to change. 37 | */ 38 | resume: () => void; 39 | } 40 | 41 | /** 42 | * Expresses interest in store value changes over time. It can be either: 43 | * - a callback function: {@link SubscriberFunction}; 44 | * - a partial observer: {@link SubscriberObject}. 45 | */ 46 | export type Subscriber = SubscriberFunction | Partial> | null | undefined; 47 | 48 | /** 49 | * A function to unsubscribe from value change notifications. 50 | */ 51 | export type UnsubscribeFunction = () => void; 52 | 53 | /** 54 | * An object with the `unsubscribe` method. 55 | * Subscribable stores might choose to return such object instead of directly returning {@link UnsubscribeFunction} from a subscription call. 56 | */ 57 | export interface UnsubscribeObject { 58 | /** 59 | * A method that acts as the {@link UnsubscribeFunction}. 60 | */ 61 | unsubscribe: UnsubscribeFunction; 62 | } 63 | 64 | export type Unsubscriber = UnsubscribeObject | UnsubscribeFunction; 65 | 66 | /** 67 | * Represents a store accepting registrations (subscribers) and "pushing" notifications on each and every store value change. 68 | */ 69 | export interface SubscribableStore { 70 | /** 71 | * A method that makes it possible to register "interest" in store value changes over time. 72 | * It is called each and every time the store's value changes. 73 | * 74 | * A registered subscriber is notified synchronously with the latest store value. 75 | * 76 | * @param subscriber - a subscriber in a form of a {@link SubscriberFunction} or a {@link SubscriberObject}. Returns a {@link Unsubscriber} (function or object with the `unsubscribe` method) that can be used to unregister and stop receiving notifications of store value changes. 77 | * @returns The {@link UnsubscribeFunction} or {@link UnsubscribeObject} that can be used to unsubscribe (stop state change notifications). 78 | */ 79 | subscribe(subscriber: Subscriber): Unsubscriber; 80 | } 81 | 82 | /** 83 | * An interface for interoperability between observable implementations. It only has to expose the `[Symbol.observable]` method that is supposed to return a subscribable store. 84 | */ 85 | export interface InteropObservable { 86 | [Symbol.observable]: () => SubscribableStore; 87 | } 88 | 89 | /** 90 | * Valid types that can be considered as a store. 91 | */ 92 | export type StoreInput = SubscribableStore | InteropObservable; 93 | 94 | /** 95 | * Represents a store that can return its value with a get method. 96 | */ 97 | export interface SignalStore { 98 | /** 99 | * Returns the value of the store. 100 | */ 101 | get(): T; 102 | } 103 | 104 | /** 105 | * This interface augments the base {@link SubscribableStore} interface by requiring the return value of the subscribe method to be both a function and an object with the `unsubscribe` method. 106 | * 107 | * For {@link https://rxjs.dev/api/index/interface/InteropObservable | interoperability with rxjs}, it also implements the `[Symbol.observable]` method. 108 | */ 109 | export interface Readable extends SubscribableStore, InteropObservable, SignalStore { 110 | subscribe(subscriber: Subscriber): UnsubscribeFunction & UnsubscribeObject; 111 | [Symbol.observable](): Readable; 112 | } 113 | 114 | /** 115 | * This interface augments the base {@link Readable} interface by adding the ability to call the store as a function to get its value. 116 | */ 117 | export interface ReadableSignal extends Readable { 118 | /** 119 | * Returns the value of the store. 120 | */ 121 | (): T; 122 | } 123 | 124 | /** 125 | * A function that can be used to update store's value. This function is called with the current value and should return new store value. 126 | */ 127 | export type Updater = (value: T) => U; 128 | 129 | /** 130 | * Builds on top of {@link Readable} and represents a store that can be manipulated from "outside": anyone with a reference to writable store can either update or completely replace state of a given store. 131 | * 132 | * @example 133 | * 134 | * ```typescript 135 | * // reset counter's store value to 0 by using the {@link Writable.set} method 136 | * counterStore.set(0); 137 | * 138 | * // increment counter's store value by using the {@link Writable.update} method 139 | * counterStore.update(currentValue => currentValue + 1); 140 | * ``` 141 | */ 142 | export interface Writable extends Readable { 143 | /** 144 | * Replaces store's state with the provided value. 145 | * @param value - value to be used as the new state of a store. 146 | */ 147 | set(value: U): void; 148 | 149 | /** 150 | * Updates store's state by using an {@link Updater} function. 151 | * @param updater - a function that takes the current state as an argument and returns the new state. 152 | */ 153 | update(updater: Updater): void; 154 | } 155 | 156 | /** 157 | * Represents a store that implements both {@link ReadableSignal} and {@link Writable}. 158 | * This is the type of objects returned by {@link writable}. 159 | */ 160 | export interface WritableSignal extends ReadableSignal, Writable {} 161 | 162 | export interface OnUseArgument { 163 | (value: T): void; 164 | set: (value: T) => void; 165 | update: (updater: Updater) => void; 166 | } 167 | 168 | /** 169 | * Type of a function that is called when the number of subscribers changes from 0 to 1 170 | * (but not called when the number of subscribers changes from 1 to 2, ...). 171 | * 172 | * If it returns a function, that function will be called when the number of subscribers changes from 1 to 0. 173 | */ 174 | export type OnUseFn = (arg: OnUseArgument) => void | Unsubscriber; 175 | 176 | /** 177 | * Store options that can be passed to {@link readable} or {@link writable}. 178 | */ 179 | export interface StoreOptions { 180 | /** 181 | * A function that is called when the number of subscribers changes from 0 to 1 182 | * (but not called when the number of subscribers changes from 1 to 2, ...). 183 | * If it returns a function, that function will be called when the number of subscribers changes from 1 to 0. 184 | */ 185 | onUse?: OnUseFn; 186 | 187 | /** 188 | * Custom function to compare two values, that should return true if they 189 | * are equal. 190 | * 191 | * It is called when setting a new value to avoid doing anything 192 | * (such as notifying subscribers) if the value did not change. 193 | * 194 | * @remarks 195 | * The default logic (when this option is not present) is to return false 196 | * if `a` is a function or an object, or if `a` and `b` are different 197 | * according to `Object.is`. 198 | * 199 | * {@link StoreOptions.equal|equal} takes precedence over {@link StoreOptions.notEqual|notEqual} if both 200 | * are defined. 201 | * 202 | * @param a - First value to compare. 203 | * @param b - Second value to compare. 204 | * @returns true if a and b are considered equal. 205 | */ 206 | equal?: (a: T, b: T) => boolean; 207 | 208 | /** 209 | * Custom function to compare two values, that should return true if they 210 | * are different. 211 | * 212 | * It is called when setting a new value to avoid doing anything 213 | * (such as notifying subscribers) if the value did not change. 214 | * 215 | * @remarks 216 | * The default logic (when this option is not present) is to return true 217 | * if `a` is a function or an object, or if `a` and `b` are different 218 | * according to `Object.is`. 219 | * 220 | * {@link StoreOptions.equal} takes precedence over {@link StoreOptions.notEqual|notEqual} if both 221 | * are defined. 222 | * 223 | * @deprecated Use {@link StoreOptions.equal} instead 224 | * @param a - First value to compare. 225 | * @param b - Second value to compare. 226 | * @returns true if a and b are considered different. 227 | */ 228 | notEqual?: (a: T, b: T) => boolean; 229 | } 230 | 231 | /** 232 | * Either a single {@link StoreInput} or a read-only array of at least one {@link StoreInput}. 233 | */ 234 | export type StoresInput = StoreInput | readonly [StoreInput, ...StoreInput[]]; 235 | 236 | /** 237 | * Extracts the types of the values of the stores from a type extending {@link StoresInput}. 238 | * 239 | * @remarks 240 | * 241 | * If the type given as a parameter is a single {@link StoreInput}, the type of the value 242 | * of that {@link StoreInput} is returned. 243 | * 244 | * If the type given as a parameter is one of an array of {@link StoreInput}, the returned type 245 | * is the type of an array containing the value of each store in the same order. 246 | */ 247 | export type StoresInputValues = 248 | S extends StoreInput 249 | ? T 250 | : { [K in keyof S]: S[K] extends StoreInput ? T : never }; 251 | 252 | export type SyncDeriveFn = (values: StoresInputValues) => T; 253 | export interface SyncDeriveOptions extends Omit, 'onUse'> { 254 | derive: SyncDeriveFn; 255 | } 256 | export type AsyncDeriveFn = ( 257 | values: StoresInputValues, 258 | set: OnUseArgument 259 | ) => Unsubscriber | void; 260 | export interface AsyncDeriveOptions extends Omit, 'onUse'> { 261 | derive: AsyncDeriveFn; 262 | } 263 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * tansu is a lightweight, push-based state management library. 3 | * It borrows the ideas and APIs originally designed and implemented by {@link https://github.com/sveltejs/rfcs/blob/master/text/0002-reactive-stores.md | Svelte stores}. 4 | * 5 | * @packageDocumentation 6 | */ 7 | 8 | import { equal } from './internal/equal'; 9 | import { 10 | exposeRawStore, 11 | getRawStore, 12 | rawStoreSymbol, 13 | symbolObservable, 14 | } from './internal/exposeRawStores'; 15 | import { RawStoreComputed } from './internal/storeComputed'; 16 | import { RawStoreConst } from './internal/storeConst'; 17 | import { 18 | createOnUseArg, 19 | RawStoreAsyncDerived, 20 | RawStoreDerivedStore, 21 | RawStoreSyncDerived, 22 | } from './internal/storeDerived'; 23 | import { RawStoreWithOnUse } from './internal/storeWithOnUse'; 24 | import { RawStoreWritable } from './internal/storeWritable'; 25 | import { noop } from './internal/subscribeConsumer'; 26 | import { untrack } from './internal/untrack'; 27 | import type { 28 | AsyncDeriveFn, 29 | AsyncDeriveOptions, 30 | OnUseFn, 31 | Readable, 32 | ReadableSignal, 33 | StoreInput, 34 | StoreOptions, 35 | StoresInput, 36 | StoresInputValues, 37 | Subscriber, 38 | SyncDeriveFn, 39 | SyncDeriveOptions, 40 | UnsubscribeFunction, 41 | UnsubscribeObject, 42 | Unsubscriber, 43 | Updater, 44 | Writable, 45 | WritableSignal, 46 | } from './types'; 47 | 48 | export { batch } from './internal/batch'; 49 | export { equal } from './internal/equal'; 50 | export { symbolObservable } from './internal/exposeRawStores'; 51 | export { untrack } from './internal/untrack'; 52 | export type * from './types'; 53 | 54 | /** 55 | * Returns a wrapper (for the given store) which only exposes the {@link ReadableSignal} interface. 56 | * This converts any {@link StoreInput} to a {@link ReadableSignal} and exposes the store as read-only. 57 | * 58 | * @param store - store to wrap 59 | * @returns A wrapper which only exposes the {@link ReadableSignal} interface. 60 | */ 61 | export function asReadable(store: StoreInput): ReadableSignal; 62 | /** 63 | * Returns a wrapper (for the given store) which only exposes the {@link ReadableSignal} interface and 64 | * also adds the given extra properties on the returned object. 65 | * 66 | * @param store - store to wrap 67 | * @param extraProp - extra properties to add on the returned object 68 | * @returns A wrapper which only exposes the {@link ReadableSignal} interface and the given extra properties. 69 | */ 70 | export function asReadable( 71 | store: StoreInput, 72 | extraProp: U 73 | ): ReadableSignal & Omit>; 74 | export function asReadable( 75 | store: StoreInput, 76 | extraProp?: U 77 | ): ReadableSignal & Omit> { 78 | return exposeRawStore(getRawStore(store), extraProp); 79 | } 80 | 81 | const defaultUpdate: any = function (this: Writable, updater: Updater) { 82 | this.set(updater(untrack(() => this.get()))); 83 | }; 84 | 85 | /** 86 | * Returns a wrapper (for the given store) which only exposes the {@link WritableSignal} interface. 87 | * When the value is changed from the given wrapper, the provided set function is called. 88 | * 89 | * @param store - store to wrap 90 | * @param set - function that will be called when the value is changed from the wrapper 91 | * (through the {@link Writable.set|set} or the {@link Writable.update|update} function). 92 | * If set is not specified, a noop function is used (so the value of the store cannot be changed 93 | * from the returned wrapper). 94 | * @returns A wrapper which only exposes the {@link WritableSignal} interface. 95 | */ 96 | export function asWritable( 97 | store: StoreInput, 98 | set?: WritableSignal['set'] 99 | ): WritableSignal; 100 | /** 101 | * Returns a wrapper (for the given store) which only exposes the {@link WritableSignal} interface and 102 | * also adds the given extra properties on the returned object. 103 | * 104 | * @param store - store to wrap 105 | * @param extraProps - object containing the extra properties to add on the returned object, 106 | * and optionally the {@link Writable.set|set} and the {@link Writable.update|update} function of the 107 | * {@link WritableSignal} interface. 108 | * If the set function is not specified, a noop function is used. 109 | * 110 | * If the update function is not specified, a default function that calls set is used. 111 | * @returns A wrapper which only exposes the {@link WritableSignal} interface and the given extra properties. 112 | */ 113 | export function asWritable( 114 | store: StoreInput, 115 | extraProps: U & Partial, 'set' | 'update'>> 116 | ): WritableSignal & Omit>; 117 | export function asWritable( 118 | store: StoreInput, 119 | setOrExtraProps?: 120 | | WritableSignal['set'] 121 | | (U & Partial, 'set' | 'update'>>) 122 | ): WritableSignal & Omit> { 123 | return asReadable( 124 | store, 125 | typeof setOrExtraProps === 'function' 126 | ? { set: setOrExtraProps, update: defaultUpdate } 127 | : { 128 | ...setOrExtraProps, 129 | set: setOrExtraProps?.set ?? noop, 130 | update: setOrExtraProps?.update ?? (setOrExtraProps?.set ? defaultUpdate : noop), 131 | } 132 | ) as any; 133 | } 134 | 135 | /** 136 | * A utility function to get the current value from a given store. 137 | * It works by subscribing to a store, capturing the value (synchronously) and unsubscribing just after. 138 | * 139 | * @param store - a store from which the current value is retrieved. 140 | * 141 | * @example 142 | * ```typescript 143 | * const myStore = writable(1); 144 | * console.log(get(myStore)); // logs 1 145 | * ``` 146 | */ 147 | export const get = (store: StoreInput): T => getRawStore(store).get(); 148 | 149 | /** 150 | * Base class that can be extended to easily create a custom {@link Readable} store. 151 | * 152 | * @example 153 | * ```typescript 154 | * class CounterStore extends Store { 155 | * constructor() { 156 | * super(1); // initial value 157 | * } 158 | * 159 | * reset() { 160 | * this.set(0); 161 | * } 162 | * 163 | * increment() { 164 | * this.update(value => value + 1); 165 | * } 166 | * } 167 | * 168 | * const store = new CounterStore(1); 169 | * 170 | * // logs 1 (initial value) upon subscription 171 | * const unsubscribe = store.subscribe((value) => { 172 | * console.log(value); 173 | * }); 174 | * store.increment(); // logs 2 175 | * store.reset(); // logs 0 176 | * 177 | * unsubscribe(); // stops notifications and corresponding logging 178 | * ``` 179 | */ 180 | export abstract class Store implements Readable { 181 | /** 182 | * 183 | * @param value - Initial value of the store 184 | */ 185 | constructor(value: T) { 186 | let rawStore; 187 | if (value instanceof RawStoreWritable) { 188 | rawStore = value; 189 | } else { 190 | const onUse = this.onUse; 191 | rawStore = onUse 192 | ? new RawStoreWithOnUse(value, onUse.bind(this)) 193 | : new RawStoreWritable(value); 194 | rawStore.equalFn = (a, b) => this.equal(a, b); 195 | } 196 | this[rawStoreSymbol] = rawStore; 197 | } 198 | 199 | /** @internal */ 200 | [rawStoreSymbol]: RawStoreWritable; 201 | 202 | /** 203 | * Compares two values and returns true if they are equal. 204 | * It is called when setting a new value to avoid doing anything 205 | * (such as notifying subscribers) if the value did not change. 206 | * The default logic is to return false if `a` is a function or an object, 207 | * or if `a` and `b` are different according to `Object.is`. 208 | * This method can be overridden by subclasses to change the logic. 209 | * 210 | * @remarks 211 | * For backward compatibility, the default implementation calls the 212 | * deprecated {@link Store.notEqual} method and returns the negation 213 | * of its return value. 214 | * 215 | * @param a - First value to compare. 216 | * @param b - Second value to compare. 217 | * @returns true if a and b are considered equal. 218 | */ 219 | protected equal(a: T, b: T): boolean { 220 | return !this.notEqual(a, b); 221 | } 222 | 223 | /** 224 | * Compares two values and returns true if they are different. 225 | * It is called when setting a new value to avoid doing anything 226 | * (such as notifying subscribers) if the value did not change. 227 | * The default logic is to return true if `a` is a function or an object, 228 | * or if `a` and `b` are different according to `Object.is`. 229 | * This method can be overridden by subclasses to change the logic. 230 | * 231 | * @remarks 232 | * This method is only called by the default implementation of 233 | * {@link Store.equal}, so overriding {@link Store.equal} takes 234 | * precedence over overriding notEqual. 235 | * 236 | * @deprecated Use {@link Store.equal} instead 237 | * @param a - First value to compare. 238 | * @param b - Second value to compare. 239 | * @returns true if a and b are considered different. 240 | */ 241 | protected notEqual(a: T, b: T): boolean { 242 | return !equal(a, b); 243 | } 244 | 245 | /** 246 | * Replaces store's state with the provided value. 247 | * Equivalent of {@link Writable.set}, but internal to the store. 248 | * 249 | * @param value - value to be used as the new state of a store. 250 | */ 251 | protected set(value: T): void { 252 | this[rawStoreSymbol].set(value); 253 | } 254 | 255 | get(): T { 256 | return this[rawStoreSymbol].get(); 257 | } 258 | 259 | /** 260 | * Updates store's state by using an {@link Updater} function. 261 | * Equivalent of {@link Writable.update}, but internal to the store. 262 | * 263 | * @param updater - a function that takes the current state as an argument and returns the new state. 264 | */ 265 | protected update(updater: Updater): void { 266 | this[rawStoreSymbol].update(updater); 267 | } 268 | 269 | /** 270 | * Function called when the number of subscribers changes from 0 to 1 271 | * (but not called when the number of subscribers changes from 1 to 2, ...). 272 | * If a function is returned, it will be called when the number of subscribers changes from 1 to 0. 273 | * 274 | * @example 275 | * 276 | * ```typescript 277 | * class CustomStore extends Store { 278 | * onUse() { 279 | * console.log('Got the fist subscriber!'); 280 | * return () => { 281 | * console.log('All subscribers are gone...'); 282 | * }; 283 | * } 284 | * } 285 | * 286 | * const store = new CustomStore(); 287 | * const unsubscribe1 = store.subscribe(() => {}); // logs 'Got the fist subscriber!' 288 | * const unsubscribe2 = store.subscribe(() => {}); // nothing is logged as we've got one subscriber already 289 | * unsubscribe1(); // nothing is logged as we still have one subscriber 290 | * unsubscribe2(); // logs 'All subscribers are gone...' 291 | * ``` 292 | */ 293 | protected onUse?(): Unsubscriber | void; 294 | 295 | /** 296 | * Default Implementation of the {@link SubscribableStore.subscribe}, not meant to be overridden. 297 | * @param subscriber - see {@link SubscribableStore.subscribe} 298 | */ 299 | subscribe(subscriber: Subscriber): UnsubscribeFunction & UnsubscribeObject { 300 | return this[rawStoreSymbol].subscribe(subscriber); 301 | } 302 | 303 | [symbolObservable](): this { 304 | return this; 305 | } 306 | } 307 | 308 | const createStoreWithOnUse = (initValue: T, onUse: OnUseFn) => { 309 | const store: RawStoreWithOnUse = new RawStoreWithOnUse(initValue, () => onUse(setFn)); 310 | const setFn = createOnUseArg(store); 311 | return store; 312 | }; 313 | 314 | const applyStoreOptions = >( 315 | store: S, 316 | options?: Omit, 'onUse'> 317 | ): S => { 318 | if (options) { 319 | const { equal, notEqual } = options; 320 | if (equal) { 321 | store.equalFn = equal; 322 | } else if (notEqual) { 323 | store.equalFn = (a: T, b: T) => !notEqual(a, b); 324 | } 325 | } 326 | return store; 327 | }; 328 | 329 | /** 330 | * A convenience function to create {@link Readable} store instances. 331 | * @param value - Initial value of a readable store. 332 | * @param options - Either an object with {@link StoreOptions | store options}, or directly the onUse function. 333 | * 334 | * The onUse function is a function called when the number of subscribers changes from 0 to 1 335 | * (but not called when the number of subscribers changes from 1 to 2, ...). 336 | * 337 | * If a function is returned, it will be called when the number of subscribers changes from 1 to 0. 338 | * 339 | * @example Sample with an onUse function 340 | * ```typescript 341 | * const clock = readable("00:00", setState => { 342 | * const intervalID = setInterval(() => { 343 | * const date = new Date(); 344 | * setState(`${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}`); 345 | * }, 1000); 346 | * 347 | * return () => clearInterval(intervalID); 348 | * }); 349 | * ``` 350 | */ 351 | export function readable(value: T, options?: StoreOptions | OnUseFn): ReadableSignal { 352 | if (typeof options === 'function') { 353 | options = { onUse: options }; 354 | } 355 | const onUse = options?.onUse; 356 | return exposeRawStore( 357 | onUse 358 | ? applyStoreOptions(createStoreWithOnUse(value, onUse), options) 359 | : new RawStoreConst(value) 360 | ); 361 | } 362 | 363 | /** 364 | * A convenience function to create {@link Writable} store instances. 365 | * @param value - initial value of a new writable store. 366 | * @param options - Either an object with {@link StoreOptions | store options}, or directly the onUse function. 367 | * 368 | * The onUse function is a function called when the number of subscribers changes from 0 to 1 369 | * (but not called when the number of subscribers changes from 1 to 2, ...). 370 | * 371 | * If a function is returned, it will be called when the number of subscribers changes from 1 to 0. 372 | * 373 | * @example 374 | * ```typescript 375 | * const x = writable(0); 376 | * 377 | * x.update(v => v + 1); // increment 378 | * x.set(0); // reset back to the default value 379 | * ``` 380 | */ 381 | export function writable(value: T, options?: StoreOptions | OnUseFn): WritableSignal { 382 | if (typeof options === 'function') { 383 | options = { onUse: options }; 384 | } 385 | const onUse = options?.onUse; 386 | const store = applyStoreOptions( 387 | onUse ? createStoreWithOnUse(value, onUse) : new RawStoreWritable(value), 388 | options 389 | ); 390 | const res = exposeRawStore(store) as any; 391 | res.set = store.set.bind(store); 392 | res.update = store.update.bind(store); 393 | return res; 394 | } 395 | 396 | type DeriveFn = SyncDeriveFn | AsyncDeriveFn; 397 | interface DeriveOptions extends Omit, 'onUse'> { 398 | derive: DeriveFn; 399 | } 400 | function isSyncDeriveFn(fn: DeriveFn): fn is SyncDeriveFn { 401 | return fn.length <= 1; 402 | } 403 | 404 | export abstract class DerivedStore extends Store { 405 | constructor(stores: S, initialValue: T) { 406 | const rawStore = new RawStoreDerivedStore(stores, initialValue, (values) => 407 | this.derive(values) 408 | ); 409 | rawStore.equalFn = (a, b) => this.equal(a, b); 410 | super(rawStore as any); 411 | } 412 | protected abstract derive(values: StoresInputValues): Unsubscriber | void; 413 | } 414 | 415 | /** 416 | * A convenience function to create a new store with a state computed from the latest values of dependent stores. 417 | * Each time the state of one of the dependent stores changes, a provided derive function is called to compute a new, derived state. 418 | * 419 | * @param stores - a single store or an array of dependent stores 420 | * @param options - either an object with store options, including a derive function, or the derive function itself directly. 421 | * The derive function is used to compute a new state based on the latest values of dependent stores. 422 | * 423 | * Alternatively, this function can accept a second argument, `set`, to manage asynchronous values. 424 | * If you return a function from the callback, it will be called when the callback runs again, or the last subscriber unsubscribes. 425 | * 426 | * @example synchronous 427 | * ```typescript 428 | * const x$ = writable(2); 429 | * const y$ = writable(3); 430 | * const sum$ = derived([x$, y$], ([x, y]) => x + y); 431 | * 432 | * // will log 5 upon subscription 433 | * sum$.subscribe((value) => { 434 | * console.log(value) 435 | * }); 436 | * 437 | * x$.set(3); // will re-evaluate the `([x, y]) => x + y` function and log 6 as this is the new state of the derived store 438 | * 439 | * ``` 440 | * 441 | * @example asynchronous 442 | * ```typescript 443 | * const x$ = writable(2); 444 | * const y$ = writable(3); 445 | * 446 | * const sum$ = derived([x$, $y], ([x, y], set) => { 447 | * const timeoutId = setTimeout(() => set(x + y))); 448 | * return () => clearTimeout(timeoutId); 449 | * }, undefined); 450 | * 451 | * // will log undefined (the default value), then 5 asynchronously 452 | * sum$.subscribe((value) => { 453 | * console.log(value) 454 | * }); 455 | * 456 | * ``` 457 | 458 | */ 459 | export function derived( 460 | stores: S, 461 | options: AsyncDeriveFn | AsyncDeriveOptions, 462 | initialValue: T 463 | ): ReadableSignal; 464 | export function derived( 465 | stores: S, 466 | options: SyncDeriveFn | SyncDeriveOptions, 467 | initialValue?: T 468 | ): ReadableSignal; 469 | export function derived( 470 | stores: S, 471 | options: DeriveFn | DeriveOptions, 472 | initialValue?: T 473 | ): ReadableSignal { 474 | if (typeof options === 'function') { 475 | options = { derive: options }; 476 | } 477 | const { derive, ...opts } = options; 478 | const Derived = isSyncDeriveFn(derive) ? RawStoreSyncDerived : RawStoreAsyncDerived; 479 | return exposeRawStore( 480 | applyStoreOptions(new Derived(stores as any, initialValue as any, derive as any), opts) 481 | ); 482 | } 483 | 484 | /** 485 | * Creates a store whose value is computed by the provided function. 486 | * 487 | * @remarks 488 | * 489 | * The computation function is first called the first time the store is used. 490 | * 491 | * It can use the value of other stores or observables and the computation function is called again if the value of those dependencies 492 | * changed, as long as the store is still used. 493 | * 494 | * Dependencies are detected automatically as the computation function gets their value either by calling the stores 495 | * as a function (as it is possible with stores implementing {@link ReadableSignal}), or by calling the {@link get} function 496 | * (with a store or any observable). If some calls made by the function should not be tracked as dependencies, it is possible 497 | * to wrap them in a call to {@link untrack}. 498 | * 499 | * Note that dependencies can change between calls of the computation function. Internally, tansu will subscribe to new dependencies 500 | * when they are used and unsubscribe from dependencies that are no longer used after the call of the computation function. 501 | * 502 | * @param fn - computation function that returns the value of the store 503 | * @param options - store option. Allows to define the {@link StoreOptions.equal|equal} function, if needed 504 | * @returns store containing the value returned by the computation function 505 | */ 506 | export function computed( 507 | fn: () => T, 508 | options?: Omit, 'onUse'> 509 | ): ReadableSignal { 510 | return exposeRawStore(applyStoreOptions(new RawStoreComputed(fn), options)); 511 | } 512 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tansu 2 | 3 | [![npm](https://img.shields.io/npm/v/@amadeus-it-group/tansu)](https://www.npmjs.com/package/@amadeus-it-group/tansu) 4 | ![build](https://github.com/AmadeusITGroup/tansu/workflows/ci/badge.svg) 5 | [![codecov](https://codecov.io/gh/AmadeusITGroup/tansu/branch/master/graph/badge.svg)](https://codecov.io/gh/AmadeusITGroup/tansu) 6 | 7 | Tansu is a lightweight, push-based framework-agnostic state management library. 8 | It borrows the ideas and APIs originally designed and implemented by [Svelte stores](https://github.com/sveltejs/rfcs/blob/master/text/0002-reactive-stores.md) 9 | and extends them with `computed` and `batch`. 10 | 11 | Main characteristics: 12 | 13 | * small conceptual surface with expressive and very flexible API (functional and class-based); 14 | * can be used to create "local" (module-level or component-level), collaborating stores; 15 | * can handle both immutable and mutable data; 16 | * results in compact code with the absolute minimum of boilerplate. 17 | 18 | Implementation wise, it is a tiny (1300 LOC) library without any external dependencies. 19 | 20 | ## Installation 21 | 22 | You can add Tansu to your project by installing the `@amadeus-it-group/tansu` package using your favorite package manager, ex.: 23 | 24 | * `yarn add @amadeus-it-group/tansu` 25 | * `npm install @amadeus-it-group/tansu` 26 | 27 | ## Usage 28 | 29 | Check out the [Tansu API documentation](https://amadeusitgroup.github.io/tansu/). 30 | 31 | The functional part of the API to manage your reactive state can be categorized into three distinct groups: 32 | 33 | - Base store: `writable` 34 | - Computed stores: `derived`, `computed`, `readable` 35 | - Utilities: `batch`, `asReadable`, `asWritable` 36 | 37 | ### writable 38 | 39 | [api documentation](https://amadeusitgroup.github.io/tansu/functions/writable.html) 40 | 41 | **Writable: A Fundamental Building Block** 42 | 43 | 44 | A `writable` serves as the foundational element of a "store" – a container designed to encapsulate a value, enabling observation and modification of its state. You can change the internal value using the `set` or `update` methods. 45 | 46 | To receive notifications whenever the value undergoes a change, the `subscribe()` method, paired with a callback function, can be employed. 47 | 48 | #### Basic usage 49 | 50 | ```typescript 51 | import {writable} from "@amadeus-it-group/tansu"; 52 | const value$ = writable(0); 53 | 54 | const unsubscribe = values$.subscribe((value) => { 55 | console.log(`value = ${value}`); 56 | }); 57 | 58 | value$.set(1); 59 | value$.update((value) => value + 1); 60 | ``` 61 | 62 | output: 63 | 64 | ```text 65 | value = 0 66 | value = 1 67 | value = 2 68 | ``` 69 | 70 | #### Setup and teardown 71 | 72 | The writable's second parameter allows for receiving notifications when at least one subscriber subscribes or when there are no more subscribers. 73 | 74 | ```typescript 75 | import {writable} from "@amadeus-it-group/tansu"; 76 | 77 | const value$ = writable(0, () => { 78 | console.log('At least one subscriber'); 79 | 80 | return () => { 81 | console.log('No more subscriber'); 82 | } 83 | }); 84 | 85 | const unsubscribe = values$.subscribe((value) => { 86 | console.log(`value = ${value}`); 87 | }); 88 | 89 | value$.set(1); 90 | unsubscribe(); 91 | ``` 92 | 93 | output: 94 | 95 | ```text 96 | At least one subscriber 97 | value = 0 98 | value = 1 99 | No more subscriber 100 | ``` 101 | 102 | ### derived 103 | 104 | [api documentation](https://amadeusitgroup.github.io/tansu/functions/derived.html) 105 | 106 | A `derived` store calculates its value based on one or more other stores provided as parameters. 107 | Since its value is derived from other stores, it is a read-only store and does not have any `set` or `update` methods. 108 | 109 | #### Single store 110 | 111 | ```typescript 112 | import {writable, derived} from "@amadeus-it-group/tansu"; 113 | 114 | const value$ = writable(1); 115 | const double$ = derived(value$, (value) => value * 2); 116 | 117 | double$.subscribe((double) => console.log('Double value', double)); 118 | value$.set(2); 119 | ``` 120 | 121 | output: 122 | 123 | ```text 124 | Double value 2 125 | Double value 4 126 | ``` 127 | 128 | #### Multiple stores 129 | 130 | ```typescript 131 | import {writable, derived} from "@amadeus-it-group/tansu"; 132 | 133 | const a$ = writable(1); 134 | const b$ = writable(1); 135 | const sum$ = derived([a$, b$], ([a, b]) => a + b); 136 | 137 | sum$.subscribe((sum) => console.log('Sum', sum)); 138 | a$.set(2); 139 | ``` 140 | 141 | output: 142 | 143 | ```text 144 | Sum 2 145 | Sum 3 146 | ``` 147 | 148 | #### Asynchronous set 149 | 150 | A `derived` can directly manipulate its value using the set method instead of relying on the returned value of the provided function. 151 | This flexibility allows you to manage asynchronous operations or apply filtering logic before updating the observable's value. 152 | 153 | ```typescript 154 | import {writable, derived} from "@amadeus-it-group/tansu"; 155 | 156 | const a$ = writable(0); 157 | const asynchronousDouble$ = derived(a$, (a, set) => { 158 | const plannedLater = setTimeout(() => set(a * 2)); 159 | return () => { 160 | // This clean-up function is called if there is no listener anymore, 161 | // or if the value of a$ changed 162 | // In this case, the function passed to setTimeout should not be called 163 | // (if it was not called already) 164 | clearTimeout(plannedLater); 165 | }; 166 | }, -1); 167 | 168 | const evenOnly$ = derived(a$, (a, set) => { 169 | if (a % 2 === 0) { 170 | set(a); 171 | } 172 | }, undefined); 173 | 174 | asynchronousDouble$.subscribe((double) => console.log('Double (asynchronous)', double)); 175 | evenOnly$.subscribe((value) => console.log('Even', value)); 176 | 177 | a$.set(1); 178 | a$.set(2); 179 | ``` 180 | 181 | output: 182 | 183 | ```text 184 | Double (asynchronous) -1 185 | Even 0 186 | Even 2 187 | Double (asynchronous) 4 188 | ``` 189 | 190 | 191 | ### computed 192 | 193 | [api documentation](https://amadeusitgroup.github.io/tansu/functions/computed.html) 194 | 195 | A `computed` store is another variant of a derived store, with the following characteristics: 196 | 197 | - **Implicit Dependencies:** Unlike in a derived store, there is no requirement to explicitly declare dependencies. 198 | 199 | - **Dynamic Dependency Listening:** Dependencies are determined based on their usage. This implies that a dependency not actively used is not automatically "listened" to, optimizing resource utilization. 200 | 201 | #### Switch map 202 | 203 | This capability to subscribe/unsubscribe to the dependency allows to create switch maps in a natural way. 204 | 205 | ```typescript 206 | import {writable, computed} from "@amadeus-it-group/tansu"; 207 | 208 | const switchToA$ = writable(true); 209 | const a$ = writable(1); 210 | const b$ = writable(0); 211 | 212 | const computedValue$ = computed(() => { 213 | if (switchToA$()) { 214 | console.log('Return a$'); 215 | return a$(); 216 | } else { 217 | console.log('Return b$'); 218 | return b$(); 219 | } 220 | }); 221 | 222 | computedValue$.subscribe((value) => console.log('Computed value:', value)); 223 | a$.set(2); 224 | switchToA$.set(false); 225 | a$.set(3); 226 | a$.set(4); 227 | switchToA$.set(true); 228 | 229 | ``` 230 | 231 | output: 232 | 233 | ```text 234 | Return a$ 235 | Computed value: 1 236 | Return a$ 237 | Computed value: 2 238 | Return b$ 239 | Computed value: 0 240 | Return a$ 241 | Computed value: 4 242 | ``` 243 | 244 | When `switchToA$.set(false)` is called, the subscription to `a$` is canceled, which means that subsequent changes to `a$` will no longer trigger the calculation., which is only performed again when switchToA$ is set back to true. 245 | 246 | ### readable 247 | 248 | [api documentation](https://amadeusitgroup.github.io/tansu/functions/readable.html) 249 | 250 | Similar to Svelte stores, this function generates a store where the value cannot be externally modified. 251 | 252 | ```typescript 253 | import {readable} from '@amadeus-it-group/tansu'; 254 | 255 | const time = readable(new Date(), (set) => { 256 | const interval = setInterval(() => { 257 | set(new Date()); 258 | }, 1000); 259 | 260 | return () => clearInterval(interval); 261 | }); 262 | ``` 263 | 264 | ### derived vs computed 265 | 266 | While derived and computed may appear similar, they exhibit distinct characteristics that can significantly impact effectiveness based on use-cases: 267 | 268 | - **Declaration of Dependencies:** 269 | - `computed`: No explicit declaration of dependencies is required, providing more flexibility in code composition. 270 | - `derived`: Requires explicit declaration of dependencies. 271 | 272 | - **Performance:** 273 | - `computed`: Better performance by re-running the function only based on changes in the stores involved in the last run. 274 | - `derived`: Re-run the function each time a dependent store changes. 275 | 276 | - **Asynchronous State:** 277 | - `computed`: Unable to manage asynchronous state. 278 | - `derived`: Can handle asynchronous state with the `set` method. 279 | 280 | - **Skipping Value Emission:** 281 | - `computed`: Does not provide a mechanism to skip emitting values. 282 | - `derived`: Allows skipping the emission of values by choosing not to call the provided `set` method. 283 | 284 | - **Setup and Teardown:** 285 | - `computed`: Lacks explicit setup and teardown methods. 286 | - `derived`: Supports setup and teardown methods, allowing actions such as adding or removing DOM listeners. 287 | - When the last listener unsubscribes and then subscribes again, the derived function is rerun due to its setup-teardown functionality. In contrast, a computed provides the last value without recomputing if dependencies haven't changed in the meantime. 288 | 289 | While `computed` feels more intuitive in many use-cases, `derived` excels in scenarios where `computed` falls short, particularly in managing asynchronous state and providing more granular control over value emissions. 290 | 291 | Carefully choosing between them based on specific requirements enhances the effectiveness of state management in your application. 292 | 293 | ### Getting the value 294 | 295 | There are three ways for getting the value of a store: 296 | 297 | ```typescript 298 | import {writable, get} from "@amadeus-it-group/tansu"; 299 | 300 | const count$ = writable(1); 301 | const unsubscribe = count$.subscribe((count) => { 302 | // Will be called with the updated value synchronously first, then each time count$ changes. 303 | // `unsubscribe` must be called to prevent future calls. 304 | console.log(count); 305 | }); 306 | 307 | // A store is also a function that you can call to get the instant value. 308 | console.log(count$()); 309 | 310 | // Equivalent to 311 | console.log(get(count$)); 312 | ``` 313 | 314 | > [!NOTE] 315 | > Getting the instant value implies the subscription and unsubription on the store: 316 | > - It can be important to know in case of setup/teardown functions. 317 | > - In the same scope, prefer to store the value once in a local variable instead of calling `store$()` several times. 318 | > 319 | > When called inside a reactive context (i.e. inside a computed), getting the value serves to know and "listen" the dependent stores. 320 | 321 | 322 | ### batch 323 | 324 | [api documentation](https://amadeusitgroup.github.io/tansu/functions/batch.html) 325 | 326 | Contrary to other libraries like Angular with signals or Svelte with runes, where the callback of a subscription is executed asynchronously (usually referenced as an "effect"), we have maintained the constraint of synchronicity between the store changes and their subscriptions in Tansu. 327 | 328 | While it is acceptable for these frameworks to defer these calls since their goals are well-known in advance (to optimize their final rendering), this is not the case for Tansu, where the goal is to be adaptable to any situation. 329 | 330 | The problem with synchronous subscriptions is that it can create "glitches". Subscribers and computed store callbacks that are run too many times can create incorrect intermediate values. 331 | 332 | See for example the [asymmetric diamond dependency problem](https://github.com/AmadeusITGroup/tansu/pull/31), which still exists in Svelte stores, while it has been fixed in Tansu. 333 | 334 | There is also another use case. 335 | 336 | Let's have a look at the following example: 337 | 338 | ```typescript 339 | import {writable, derived} from '@amadeus-it-group/tansu'; 340 | 341 | const firstName = writable('Arsène'); 342 | const lastName = writable('Lupin'); 343 | const fullName = derived([firstName, lastName], ([a, b]) => `${a} ${b}`); 344 | fullName.subscribe((name) => console.log(name)); // logs any change to fullName 345 | firstName.set('Sherlock'); 346 | lastName.set('Holmes'); 347 | console.log('Process end'); 348 | ``` 349 | 350 | output: 351 | 352 | ```text 353 | Arsène Lupin 354 | Sherlock Lupin 355 | Sherlock Holmes 356 | Process end 357 | ``` 358 | 359 | The fullName store successively went through different states, including an inconsistent one, as `Sherlock Lupin` does not exist! Even if it can be seen as just an intermediate state, it is **fundamental** for a state management to only manage consistent data in order to prevent issues and optimize the code. 360 | 361 | In Tansu, the `batch` is available to defer **synchronously** the subscribers calls, and de facto the dependent `derived` or `computed` calculation to solve all kind of multiple dependencies issues. 362 | 363 | The previous example is resolved this way: 364 | 365 | ```typescript 366 | import {writable, derived, computed, batch} from '@amadeus-it-group/tansu'; 367 | 368 | const firstName = writable('Arsène'); 369 | const lastName = writable('Lupin'); 370 | const fullName = derived([firstName, lastName], ([a, b]) => `${a} ${b}`); 371 | // note that the fullName store could alternatively be create with computed: 372 | // const fullName = computed(() => `${firstName()} ${lastName()}`); 373 | fullName.subscribe((name) => console.log(name)); // logs any change to fullName 374 | batch(() => { 375 | firstName.set('Sherlock'); 376 | lastName.set('Holmes'); 377 | }); 378 | console.log('Process end'); 379 | ``` 380 | 381 | output: 382 | 383 | ```text 384 | Arsène Lupin 385 | Sherlock Holmes 386 | Process end 387 | ``` 388 | > [!NOTE] 389 | > - Retrieving the immediate value of a store (using any of the three methods mentioned earlier: calling `subscribe` with a subscriber that will be called with the value synchronously, using `get` or calling the store as a function) always provides the value based on the up-to-date values of all dependent stores (even if this requires re-computations of a computed or a derived inside the batch) 390 | > - all calls to subscribers (excluding the first synchronous call during the subscribe process) are deferred until the end of the batch 391 | > - if a subscriber has already been notified of a new value inside the batch (for example, when it is a new subscriber registered within the batch), it won't be notified again at the end of the batch if the value remains unchanged. Subscribers are invoked only when the store's value has changed since their last call. 392 | > - `batch` can be called inside `batch`. The subscriber calls are performed at the end of the first batch, synchronously. 393 | 394 | 395 | ### asReadable 396 | 397 | [api documentation](https://amadeusitgroup.github.io/tansu/functions/asReadable.html) 398 | 399 | `asReadable` returns a new store that exposes only the essential elements needed for subscribing to the store. It also includes any extra methods passed as parameters. 400 | 401 | This is useful and widely used to compose a custom store: 402 | 403 | - The first parameter is the writable store, 404 | - The second parameter is an object to extend the readable store returned. 405 | 406 | ```typescript 407 | import {writable, asReadable} from "@amadeus-it-group/tansu"; 408 | 409 | function createCounter(initialValue: number) { 410 | const store$ = writable(initialValue); 411 | 412 | return asReadable(store$, { 413 | increment: () => store$.update((value) => value + 1), 414 | decrement: () => store$.update((value) => value - 1), 415 | reset: () => store$.set(initialValue) 416 | }); 417 | } 418 | 419 | const counter$ = createCounter(0); 420 | 421 | counter$.subscribe((value) => console.log('Value: ', value)); 422 | 423 | counter$.increment(); 424 | counter$.reset(); 425 | counter$.set(2); // Error, set does not exist 426 | ``` 427 | output: 428 | ```text 429 | Value: 0 430 | Value: 1 431 | Value: 0 432 | (Error thrown !) 433 | ``` 434 | 435 | ### asWritable 436 | 437 | [api documentation](https://amadeusitgroup.github.io/tansu/functions/asWritable.html) 438 | 439 | `asWritable` is almost the same as an `asReadable`, with the key difference being its implementation of the [WritableSignal](https://amadeusitgroup.github.io/tansu/interfaces/WritableSignal.html) interface. 440 | 441 | It's useful when you want to connect your computed store to the original one, or implement a custom `set` method. The `set` method can be passed directly in the second parameter or within an object, similar to the usage in `asReadable`. 442 | 443 | For example: 444 | 445 | ```typescript 446 | import {writable, asWritable} from "@amadeus-it-group/tansu"; 447 | 448 | const number$ = writable(1); 449 | const double$ = computed(() => number$() * 2); 450 | const writableDouble$ = asWritable(double$, (doubleNumber) => { 451 | number$.set(doubleNumber / 2); 452 | }); 453 | /* equivalent to: 454 | const writableDouble$ = asWritable(double$, { 455 | set: (doubleNumber) => number$.set(doubleNumber / 2) 456 | }); 457 | */ 458 | 459 | writableDouble$.subscribe((value) => console.log('Value: ', value)); 460 | 461 | writableDouble$.set(2); // Nothing is triggered here, as number$ is already set with 1 462 | writableDouble$.set(4); 463 | 464 | ``` 465 | output: 466 | ```text 467 | Value: 2 468 | Value: 4 469 | ``` 470 | 471 | ## Integration in frameworks 472 | 473 | ### Tansu works well with the Svelte framework 474 | 475 | Tansu is designed to be and to remain fully compatible with Svelte. 476 | 477 | ### Tansu works well with the Angular ecosystem 478 | 479 | Here is an example of an Angular component using a Tansu store: 480 | 481 | ```typescript 482 | import { Component } from "@angular/core"; 483 | import { AsyncPipe } from '@angular/common'; 484 | import { Store, computed, get } from "@amadeus-it-group/tansu"; 485 | 486 | // A store is a class extending Store from Tansu 487 | class CounterStore extends Store { 488 | constructor() { 489 | super(0); // initialize store's value (state) 490 | } 491 | 492 | // implement state manipulation logic as regular methods 493 | increment() { 494 | // create new state based on the current state 495 | this.update(value => value + 1); 496 | } 497 | 498 | reset() { 499 | // replace the entire state with a new value 500 | this.set(0); 501 | } 502 | } 503 | 504 | @Component({ 505 | selector: "my-app", 506 | template: ` 507 |
508 | 509 | 510 | Counter: {{ counter$ | async }}
511 | Double counter: {{ doubleCounter$ | async }}
512 | `, 513 | standalone: true, 514 | imports: [AsyncPipe] 515 | }) 516 | export class App { 517 | // A store can be instantiated directly or registered in the DI container 518 | counter$ = new CounterStore(); 519 | 520 | // One can easily create computed values by specifying a transformation function 521 | doubleCounter$ = computed(() => 2 * get(this.counter$)); 522 | } 523 | ``` 524 | 525 | While being fairly minimal, this example demonstrates most of the Tansu APIs with Angular. 526 | 527 | * works with the standard `async` pipe out of the box 528 | * stores can be registered in the dependency injection (DI) container at any level (module or component injector) 529 | * stores can be used easily with rxjs because they implement the `InteropObservable` interface 530 | * conversely, rxjs observables (or any object implementing the `InteropObservable` interface) can easily be used with Tansu (e.g. in Tansu `computed` or `derived`). 531 | 532 | ## Contributing to the project 533 | 534 | Please check the [DEVELOPER.md](DEVELOPER.md) for documentation on building and testing the project on your local development machine. 535 | 536 | ## Credits and the prior art 537 | 538 | * [Svelte](https://github.com/sveltejs/rfcs/blob/master/text/0002-reactive-stores.md) gets all the credit for the initial idea and the API design. 539 | * [NgRx component stores](https://hackmd.io/zLKrFIadTMS2T6zCYGyHew?view) solve a similar problem with a different approach. 540 | --------------------------------------------------------------------------------