@exodus/atoms 9.0.0 → 9.0.1
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 +10 -0
- package/README.md +160 -17
- package/lib/effects/wait-until.js +1 -1
- package/lib/enforce-rules.js +6 -2
- package/lib/enhancers/block-until.js +1 -1
- package/lib/enhancers/compute.js +4 -2
- package/lib/factories/storage.js +4 -1
- package/lib/simple-observer.d.ts +1 -0
- package/lib/simple-observer.js +1 -0
- package/lib/utils/memoize.d.ts +4 -0
- package/lib/utils/memoize.js +57 -0
- package/lib/utils/types.d.ts +3 -0
- package/package.json +6 -4
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,16 @@
|
|
|
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.1](https://github.com/ExodusMovement/exodus-hydra/compare/@exodus/atoms@9.0.0...@exodus/atoms@9.0.1) (2025-01-31)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
- fix: reset call state after atom reset (#11306)
|
|
11
|
+
|
|
12
|
+
### Performance
|
|
13
|
+
|
|
14
|
+
- perf(atoms): memoize compute enhancer selectors (#10771)
|
|
15
|
+
|
|
6
16
|
## [9.0.0](https://github.com/ExodusMovement/exodus-hydra/compare/@exodus/atoms@8.1.1...@exodus/atoms@9.0.0) (2024-09-26)
|
|
7
17
|
|
|
8
18
|
### ⚠ 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)**:
|
|
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
|
|
23
|
-
| ------------- |
|
|
24
|
-
| Memory | ✅
|
|
25
|
-
| Storage | ✅
|
|
26
|
-
|
|
|
27
|
-
|
|
|
28
|
-
| Event emitter | ✅ | ❌ | ✅ |
|
|
22
|
+
| | get | set | observe |
|
|
23
|
+
| ------------- | --- | ----- | ------- |
|
|
24
|
+
| Memory | ✅ | ✅ | ✅ |
|
|
25
|
+
| Storage | ✅ | ✅ | ✅ |
|
|
26
|
+
| Keystore | ✅ | 🟡 \* | ✅ |
|
|
27
|
+
| Event emitter | ✅ | ❌ | ✅ |
|
|
29
28
|
|
|
30
|
-
\*
|
|
29
|
+
\* A keystore atom needs a special `isSoleWriter` param to allow write access.
|
|
31
30
|
|
|
32
|
-
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
142
|
+
### dedupe(atom)
|
|
62
143
|
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
```
|
package/lib/enforce-rules.js
CHANGED
|
@@ -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
|
|
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;
|
package/lib/enhancers/compute.js
CHANGED
|
@@ -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
|
|
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
|
|
15
|
+
const selected = await memoizedSelector(values);
|
|
14
16
|
if (called && prev === selected)
|
|
15
17
|
return;
|
|
16
18
|
called = true;
|
package/lib/factories/storage.js
CHANGED
|
@@ -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/simple-observer.d.ts
CHANGED
|
@@ -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
|
};
|
package/lib/simple-observer.js
CHANGED
|
@@ -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
|
+
}
|
package/lib/utils/types.d.ts
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "9.0.1",
|
|
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
|
|
44
|
-
"@exodus/storage-
|
|
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": "
|
|
53
|
+
"gitHead": "1bacabfb80e78c2253cc20e3abafd7602663f37e"
|
|
52
54
|
}
|