@exodus/atoms 9.0.0 → 9.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,20 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [9.0.2](https://github.com/ExodusMovement/exodus-hydra/compare/@exodus/atoms@9.0.1...@exodus/atoms@9.0.2) (2025-03-19)
7
+
8
+ **Note:** Version bump only for package @exodus/atoms
9
+
10
+ ## [9.0.1](https://github.com/ExodusMovement/exodus-hydra/compare/@exodus/atoms@9.0.0...@exodus/atoms@9.0.1) (2025-01-31)
11
+
12
+ ### Bug Fixes
13
+
14
+ - fix: reset call state after atom reset (#11306)
15
+
16
+ ### Performance
17
+
18
+ - perf(atoms): memoize compute enhancer selectors (#10771)
19
+
6
20
  ## [9.0.0](https://github.com/ExodusMovement/exodus-hydra/compare/@exodus/atoms@8.1.1...@exodus/atoms@9.0.0) (2024-09-26)
7
21
 
8
22
  ### ⚠ BREAKING CHANGES
package/README.md CHANGED
@@ -11,30 +11,47 @@
11
11
  An atom is a data source wrapper that exposes a single piece of data through 3 different methods:
12
12
 
13
13
  - **get()**: read data
14
- - **set(newValue)**: write data
14
+ - **set(newValue)**: sets the atoms value, blocking until all observers have resolved.
15
15
  - **observe(async (data) => {})**: observes data changes. Will be called initially with current data value. Observers are awaited in series.
16
16
  - **reset()**: clear the stored value. The next `get()` call will return the default value\* and observers will be called with the default value.
17
17
 
18
18
  ## Data sources
19
19
 
20
- This library provides helpers for creating atoms from multiple data sources we use in our apps
20
+ This library provides helpers for creating atoms from multiple data sources we use in our apps.
21
21
 
22
- | | get | set | observe |
23
- | ------------- | ----- | ------- | ------- |
24
- | Memory | ✅ \* | ✅ | ✅ |
25
- | Storage | ✅ | 🟡 \*\* | ✅ |
26
- | Remote config | ✅ | | ✅ |
27
- | Local config | ✅ | | ✅ |
28
- | Event emitter | ✅ | ❌ | ✅ |
22
+ | | get | set | observe |
23
+ | ------------- | --- | ----- | ------- |
24
+ | Memory | ✅ | ✅ | ✅ |
25
+ | Storage | ✅ | | ✅ |
26
+ | Keystore | ✅ | 🟡 \* | ✅ |
27
+ | Event emitter | ✅ | | ✅ |
29
28
 
30
- \* If no `defaultValue` is provided, a memory atom's `get()` method will hang and observers will NOT be called until the first `set()` call.
29
+ \* A keystore atom needs a special `isSoleWriter` param to allow write access.
31
30
 
32
- \*\* A storage atom needs a special `isSoleWriter` param to allow write access. This is because storage instances can overlap, e.g. a parent namespace can mutate a child namespace, and our [storage-spec](https://github.com/ExodusMovement/exodus-hydra/tree/master/modules/storage-spec) doesn't currently provide for detecting changes across those instances.
31
+ See also:
32
+
33
+ - [@exodus/remote-config-atoms](../remote-config-atoms)
34
+ - [@exodus/fusion-atoms](../fusion-atoms)
35
+
36
+ Note: this library originally hosted a bunch of media-specific factories, which have since been moved out, like the two above. The above will likely follow suit, and this library will only implement the common media-agnostic atom behaviors.
37
+
38
+ ## Troubleshooting
39
+
40
+ Theoretically all atoms should behave similarly. In practice, there are a few currently inconsistent behaviors, which we aim to fix in the future, particularly around memory atoms and atoms created from an event emitter:
41
+
42
+ - Memory atoms hang on `get()` if no `defaultValue` is provided.
43
+ - Memory and Event Emitter atom observers are non-blocking, i.e. `memoryAtom.set()` is fire-and-forget
33
44
 
34
45
  ## Usage
35
46
 
36
47
  ```js
37
- import { createInMemoryAtom, createStorageAtomFactory, fromEventEmitter } from '@exodus/atoms'
48
+ import { EventEmitter } from 'events'
49
+ import {
50
+ createInMemoryAtom,
51
+ createStorageAtomFactory,
52
+ fromEventEmitter,
53
+ createKeystoreAtom,
54
+ } from '@exodus/atoms'
38
55
 
39
56
  // In memory atoms
40
57
  const availableAssetNamesAtom = createInMemoryAtom({
@@ -51,19 +68,145 @@ const acceptedTermsAtom = storageAtomFactory({
51
68
  })
52
69
 
53
70
  // Event emitter
71
+ const geolocation = new EventEmitter()
54
72
  const geolocationAtom = fromEventEmitter({
55
73
  emitter: geolocation,
56
74
  event: 'geolocation',
57
- get: geolocation.get,
75
+ get: async () =>
76
+ new Promise((resolve, reject) => navigator.geolocation.getCurrentPosition(resolve, reject)),
77
+ })
78
+
79
+ navigator.geolocation.watchPosition((position) => {
80
+ geolocation.emit('geolocation', position)
81
+ })
82
+
83
+ // keystore
84
+ const keystoreAtom = createKeystoreAtom({
85
+ keystore, // see @exodus/keystore-mobile
86
+ config: {
87
+ key: 'my-secret',
88
+ defaultValue, // optional
89
+ isSoleWriter, // if you plan to call set() on this atom instance
90
+ },
91
+ })
92
+ ```
93
+
94
+ ## Enhancers
95
+
96
+ To compute derived values, combine multiple atoms into, and perform other useful derivations, there are a bunch of enhancers available. Below is a non-exhaustive list, so check out [./src/enhancers](https://github.com/ExodusOSS/hydra/tree/master/libraries/atoms/src/enhancers) for more.
97
+
98
+ ### compute({ atom, selector }): ReadonlyAtom
99
+
100
+ Computes an atom from another by applying a selector function to the observed data source.
101
+
102
+ Example:
103
+
104
+ ```js
105
+ import { createInMemoryAtom, compute } from '@exodus/atoms'
106
+
107
+ const yearAtom = createInMemoryAtom({ defaultValue: 2025 })
108
+
109
+ const isDoorsOfStoneOutYetAtom = compute({
110
+ atom: yearAtom,
111
+ selector: (year) => year > 2040,
112
+ })
113
+
114
+ isDoorsOfStoneOutYetAtom.observe(console.log) // false
115
+ ```
116
+
117
+ ### combine({ [key]: Atom }): ReadonlyAtom
118
+
119
+ Combines multiple atoms into one:
120
+
121
+ - `combinedAtom.observe`: fires for the first time when all atoms have emitted a value.
122
+ - `combinedAtom.get`: resolves to an object with the values of all atoms as keyed in the input.
123
+
124
+ Example:
125
+
126
+ ```js
127
+ import { createInMemoryAtom, combine } from '@exodus/atoms'
128
+
129
+ const nameAtom = createInMemoryAtom()
130
+ const ageAtom = createInMemoryAtom()
131
+ const userAtom = combine({
132
+ name: nameAtom,
133
+ age: ageAtom,
58
134
  })
135
+
136
+ userAtom.observe(console.log) // hangs until both name and age are set
137
+ nameAtom.set('Voldemort')
138
+ ageAtom.set(25)
139
+ // userAtom atom fires with { name: 'Voldemort', age: 25 }
59
140
  ```
60
141
 
61
- ## Helper functions
142
+ ### dedupe(atom)
62
143
 
63
- ### compute({ atom, selector })
144
+ By default, atoms perform a shallow equality check to determine if a newly written value differs from the current one, and avoid notifying observers if it doesn't. If you want a deep equality check, use `dedupe`. (Even better, don't write deeply equal objects to that atom in the first place, and don't use `dedupe`!)
145
+
146
+ Example:
147
+
148
+ ```js
149
+ import { createInMemoryAtom, dedupe } from '@exodus/atoms'
64
150
 
65
- Computes an atom from another by applying a selector function to the observed data source. Returned atom is read-only, i.e. **set** will fail.
151
+ const userAtom = createInMemoryAtom({ defaultValue: { name: 'Voldemort', age: 25 } })
152
+ const dedupedUserAtom = dedupe(userAtom)
153
+ userAtom.set({ name: 'Voldemort', age: 25 }) // `userAtom` observers are notified
154
+ dedupedUserAtom.set({ name: 'Voldemort', age: 25 }) // `dedupedUserAtom` observers are NOT notified
155
+ ```
66
156
 
67
157
  ### withSerialization({ atom, serialize, deserialize })
68
158
 
69
- Computes an atom from another by serializing it's data after reading it and deserializing it before writing it.
159
+ If you're storing data in an atom that needs to (de)serialize it, e.g. a storage atom, and the data doesn't survive a roundtrip through JSON.stringify / JSON.parse, use `withSerialization` to provide custom serialization.
160
+
161
+ Example:
162
+
163
+ ```js
164
+ import BJSON from 'buffer-json'
165
+ import { createInMemoryAtom, withSerialization } from '@exodus/atoms'
166
+
167
+ const rawPublicKeysAtom = createInMemoryAtom()
168
+ const publicKeysAtom = withSerialization({
169
+ atom: rawPublicKeysAtom,
170
+ serialize: BJSON.stringify,
171
+ deserialize: BJSON.parse,
172
+ })
173
+
174
+ publicKeysAtom.set({
175
+ bitcoin: Buffer.from([...]),
176
+ ethereum: Buffer.from([...]),
177
+ })
178
+ ```
179
+
180
+ ### difference(atom)
181
+
182
+ If you want to get both the current and previous value emitted by an atom, use `difference`.
183
+
184
+ Example:
185
+
186
+ ```js
187
+ import { createInMemoryAtom, difference } from '@exodus/atoms'
188
+
189
+ const nameAtom = createInMemoryAtom({ defaultValue: 'Tom' })
190
+ const nameChangeAtom = difference(nameAtom)
191
+ nameChangeAtom.observe(console.log)
192
+ nameAtom.set('Voldemort')
193
+ // nameChangeAtom emits
194
+ // { previous: 'Tom', current: 'Voldemort' }
195
+ ```
196
+
197
+ ### filter(atom, predicate)
198
+
199
+ If you're only interested in a subset of values an atom emits, use `filter`:
200
+
201
+ Example:
202
+
203
+ ```js
204
+ import { createInMemoryAtom, filter } from '@exodus/atoms'
205
+
206
+ const nameAtom = createInMemoryAtom({ defaultValue: 'Tom' })
207
+ const unusualNameAtom = filter(nameAtom, (name) => !['Tom', 'Dick', 'Harry'].includes(name))
208
+ unusualNameAtom.observe(console.log)
209
+ nameAtom.set('Dick') // unusualNameAtom doesn't emit
210
+ nameAtom.set('Harry') // unusualNameAtom doesn't emit
211
+ nameAtom.set('Voldemort') // unusualNameAtom emits 'Voldemort'
212
+ ```
@@ -16,7 +16,7 @@ const waitUntil = ({ atom, predicate, rejectAfter }) => {
16
16
  }, rejectAfter);
17
17
  }
18
18
  });
19
- Object.defineProperty(promise, 'unobserve', {
19
+ void Object.defineProperty(promise, 'unobserve', {
20
20
  value: () => {
21
21
  unobserve();
22
22
  clearTimeout(timeout);
@@ -30,7 +30,7 @@ const enforceObservableRules = ({ defaultValue, makeGetNonConcurrent = false, ge
30
30
  called = true;
31
31
  return enqueue(() => listener(value));
32
32
  };
33
- nonConcurrentGet().then((value) => {
33
+ void nonConcurrentGet().then((value) => {
34
34
  if (!subscribed)
35
35
  return;
36
36
  if (!called) {
@@ -38,7 +38,7 @@ const enforceObservableRules = ({ defaultValue, makeGetNonConcurrent = false, ge
38
38
  publishSerially(postProcessValue(value));
39
39
  }
40
40
  });
41
- const unsubscribe = atom.observe((value) => {
41
+ const subscriber = (value) => {
42
42
  if (valueEmittedFromGet !== undefined) {
43
43
  const isAlreadyEmitted = value === valueEmittedFromGet;
44
44
  valueEmittedFromGet = undefined;
@@ -47,7 +47,11 @@ const enforceObservableRules = ({ defaultValue, makeGetNonConcurrent = false, ge
47
47
  }
48
48
  }
49
49
  return publishSerially(postProcessValue(value));
50
+ };
51
+ Object.defineProperty(subscriber, 'resetCallState', {
52
+ value: () => (called = false),
50
53
  });
54
+ const unsubscribe = atom.observe(subscriber);
51
55
  return () => {
52
56
  unsubscribe();
53
57
  subscribed = false;
@@ -6,7 +6,7 @@ export default function blockUntil({ atom, unblock }) {
6
6
  observe: (listener) => {
7
7
  let isSubscribed = true;
8
8
  let unsubscribe = () => { };
9
- unblock().then(() => {
9
+ void unblock().then(() => {
10
10
  if (isSubscribed) {
11
11
  unsubscribe = atom.observe(listener);
12
12
  }
@@ -1,7 +1,9 @@
1
+ import { safeMemoize } from '../utils/memoize.js';
1
2
  const compute = ({ atom, selector }) => {
3
+ const memoizedSelector = safeMemoize((value) => selector(value));
2
4
  const get = async () => {
3
5
  const values = await atom.get();
4
- return selector(values);
6
+ return memoizedSelector(values);
5
7
  };
6
8
  const set = async () => {
7
9
  throw new Error('selected atom does not support set');
@@ -10,7 +12,7 @@ const compute = ({ atom, selector }) => {
10
12
  let prev;
11
13
  let called;
12
14
  return atom.observe(async (values) => {
13
- const selected = await selector(values);
15
+ const selected = await memoizedSelector(values);
14
16
  if (called && prev === selected)
15
17
  return;
16
18
  called = true;
@@ -3,7 +3,7 @@ import enforceObservableRules from '../enforce-rules.js';
3
3
  const createStorageAtomFactory = ({ storage }) => {
4
4
  function createStorageAtom({ key, defaultValue }) {
5
5
  let version = 0;
6
- const { notify, observe } = createSimpleObserver({ enable: true });
6
+ const { notify, observe, listeners } = createSimpleObserver({ enable: true });
7
7
  let canUseCached = false;
8
8
  let cached;
9
9
  let pendingWrite;
@@ -23,6 +23,9 @@ const createStorageAtomFactory = ({ storage }) => {
23
23
  })();
24
24
  await pendingWrite;
25
25
  await notify(value);
26
+ if (!canUseCached && listeners.length > 0) {
27
+ listeners.forEach((listener) => listener.resetCallState());
28
+ }
26
29
  };
27
30
  const get = async () => {
28
31
  if (pendingWrite) {
package/lib/index.d.ts CHANGED
@@ -22,4 +22,4 @@ export { default as withStorageCache } from './enhancers/with-storage-cache.js';
22
22
  export { default as waitUntil } from './effects/wait-until.js';
23
23
  export { default as enforceObservableRules } from './enforce-rules.js';
24
24
  export { default as fromEventEmitter } from './event-emitter.js';
25
- export type { Atom, ReadonlyAtom, Listener, Unsubscribe } from './utils/types.js';
25
+ export type * from './utils/types.js';
@@ -2,6 +2,7 @@ import type { Listener } from './utils/types.js';
2
2
  declare const createSimpleObserver: <T>({ enable }?: {
3
3
  enable?: boolean | undefined;
4
4
  }) => {
5
+ listeners: Listener<T>[];
5
6
  observe: (listener: Listener<T>) => () => void;
6
7
  notify: (value: T) => Promise<void[]>;
7
8
  };
@@ -11,6 +11,7 @@ const createSimpleObserver = ({ enable = true } = {}) => {
11
11
  };
12
12
  };
13
13
  return {
14
+ listeners,
14
15
  observe,
15
16
  notify,
16
17
  };
@@ -0,0 +1,4 @@
1
+ export type ModelLike = Record<string, any> & {
2
+ toJSON: () => object;
3
+ };
4
+ export declare function safeMemoize<T extends (...args: any[]) => any>(fn: T): T;
@@ -0,0 +1,57 @@
1
+ import { memoize } from '@exodus/basic-utils';
2
+ function processValue(value, seen) {
3
+ if (value === null || value === undefined) {
4
+ return value;
5
+ }
6
+ if (typeof value !== 'object') {
7
+ const isSpecialType = ['symbol', 'function', 'bigint'].includes(typeof value);
8
+ return isSpecialType ? value.toString() : value;
9
+ }
10
+ if (seen.has(value)) {
11
+ return '[Circular]';
12
+ }
13
+ seen.add(value);
14
+ if (isModelWithToJSON(value)) {
15
+ return value.toJSON();
16
+ }
17
+ if (value instanceof RegExp) {
18
+ return value.toString();
19
+ }
20
+ if (value instanceof Date) {
21
+ return value.toISOString();
22
+ }
23
+ if (value instanceof Map) {
24
+ return [...value.entries()].map(([k, v]) => [k, processValue(v, seen)]);
25
+ }
26
+ if (value instanceof Set) {
27
+ return [...value].map((v) => processValue(v, seen));
28
+ }
29
+ if (Array.isArray(value)) {
30
+ return value.map((v) => processValue(v, seen));
31
+ }
32
+ const result = {};
33
+ for (const [key, val] of Object.entries(value)) {
34
+ if (val !== undefined) {
35
+ result[key] = processValue(val, seen);
36
+ }
37
+ }
38
+ return result;
39
+ }
40
+ function safeSerialize(value) {
41
+ try {
42
+ return JSON.stringify(processValue(value, new WeakSet()));
43
+ }
44
+ catch {
45
+ console.error('Could not serialize value:', value);
46
+ throw new Error('Value could not be serialized for memoization');
47
+ }
48
+ }
49
+ function isModelWithToJSON(value) {
50
+ return (typeof value === 'object' &&
51
+ value !== null &&
52
+ 'toJSON' in value &&
53
+ typeof value.toJSON === 'function');
54
+ }
55
+ export function safeMemoize(fn) {
56
+ return memoize(fn, safeSerialize);
57
+ }
@@ -1,5 +1,8 @@
1
1
  export type Unsubscribe = () => void;
2
2
  export type Listener<T> = (value: T) => Promise<void> | void;
3
+ export type ResettableListener<T> = Listener<T> & {
4
+ resetCallState: () => void;
5
+ };
3
6
  export interface EventEmitter {
4
7
  on<T>(event: string, listener: Listener<T>): void;
5
8
  removeListener<T>(event: string, listener: Listener<T>): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/atoms",
3
- "version": "9.0.0",
3
+ "version": "9.0.2",
4
4
  "description": "Abstraction for encapsulating a piece of data behind a simple unified interface: get, set, observe",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -28,6 +28,7 @@
28
28
  "url": "https://github.com/ExodusMovement/exodus-hydra/issues?q=is%3Aissue+is%3Aopen+label%3Aatoms"
29
29
  },
30
30
  "dependencies": {
31
+ "@exodus/basic-utils": "^3.2.0",
31
32
  "@exodus/storage-interface": "^1.0.0",
32
33
  "delay": "^5.0.0",
33
34
  "eventemitter3": "^4.0.7",
@@ -40,13 +41,14 @@
40
41
  },
41
42
  "devDependencies": {
42
43
  "@exodus/atom-tests": "^1.0.0",
43
- "@exodus/storage-encrypted": "^1.4.1",
44
- "@exodus/storage-memory": "^2.2.1",
44
+ "@exodus/deferring-storage": "^1.0.2",
45
+ "@exodus/storage-encrypted": "^1.4.2",
46
+ "@exodus/storage-memory": "^2.2.2",
45
47
  "@types/jest": "^29.5.11",
46
48
  "@types/json-stringify-safe": "^5.0.3",
47
49
  "@types/lodash": "^4.14.200",
48
50
  "@types/minimalistic-assert": "^1.0.2",
49
51
  "events": "^3.3.0"
50
52
  },
51
- "gitHead": "bbcb4c47a53d1770a36213ba77fa1f46bccc8d64"
53
+ "gitHead": "86e9ba86050f754e564bcba758ccee8f9797c32d"
52
54
  }